@volter/twin-standard 1.0.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 (69) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +20 -0
  3. package/dist/src/check-sources.d.ts +33 -0
  4. package/dist/src/check-sources.js +128 -0
  5. package/dist/src/cli.d.ts +2 -0
  6. package/dist/src/cli.js +105 -0
  7. package/dist/src/derive.d.ts +2 -0
  8. package/dist/src/derive.js +349 -0
  9. package/dist/src/gate.d.ts +9 -0
  10. package/dist/src/gate.js +109 -0
  11. package/dist/src/grade.d.ts +30 -0
  12. package/dist/src/grade.js +36 -0
  13. package/dist/src/index.d.ts +7 -0
  14. package/dist/src/index.js +11 -0
  15. package/dist/src/lanes.d.ts +2 -0
  16. package/dist/src/lanes.js +33 -0
  17. package/dist/src/p3-rules.d.ts +8 -0
  18. package/dist/src/p3-rules.js +43 -0
  19. package/dist/src/protocol-3.d.ts +5 -0
  20. package/dist/src/protocol-3.js +721 -0
  21. package/dist/src/published.d.ts +13 -0
  22. package/dist/src/published.js +36 -0
  23. package/dist/src/spec-documents.d.ts +12 -0
  24. package/dist/src/spec-documents.js +61 -0
  25. package/dist/src/spec-ir-client.d.ts +20 -0
  26. package/dist/src/spec-ir-client.js +77 -0
  27. package/dist/src/spec-ir-commands.d.ts +38 -0
  28. package/dist/src/spec-ir-commands.js +38 -0
  29. package/dist/src/spec-ir-discovery.d.ts +11 -0
  30. package/dist/src/spec-ir-discovery.js +106 -0
  31. package/dist/src/spec-ir-graphql.d.ts +25 -0
  32. package/dist/src/spec-ir-graphql.js +46 -0
  33. package/dist/src/spec-ir-lines.d.ts +19 -0
  34. package/dist/src/spec-ir-lines.js +76 -0
  35. package/dist/src/spec-ir-proto.d.ts +80 -0
  36. package/dist/src/spec-ir-proto.js +339 -0
  37. package/dist/src/spec-ir.d.ts +104 -0
  38. package/dist/src/spec-ir.js +691 -0
  39. package/dist/src/spec-patches.d.ts +8 -0
  40. package/dist/src/spec-patches.js +24 -0
  41. package/dist/src/spec.d.ts +5 -0
  42. package/dist/src/spec.js +7 -0
  43. package/dist/src/types.d.ts +75 -0
  44. package/dist/src/types.js +4 -0
  45. package/dist/src/unit.d.ts +5 -0
  46. package/dist/src/unit.js +14 -0
  47. package/package.json +71 -0
  48. package/src/check-sources.ts +109 -0
  49. package/src/cli.ts +75 -0
  50. package/src/derive.ts +316 -0
  51. package/src/gate.ts +95 -0
  52. package/src/grade.ts +47 -0
  53. package/src/index.ts +12 -0
  54. package/src/lanes.ts +29 -0
  55. package/src/p3-rules.ts +44 -0
  56. package/src/protocol-3.ts +617 -0
  57. package/src/published.ts +37 -0
  58. package/src/spec-documents.ts +58 -0
  59. package/src/spec-ir-client.ts +86 -0
  60. package/src/spec-ir-commands.ts +50 -0
  61. package/src/spec-ir-discovery.ts +104 -0
  62. package/src/spec-ir-graphql.ts +64 -0
  63. package/src/spec-ir-lines.ts +76 -0
  64. package/src/spec-ir-proto.ts +289 -0
  65. package/src/spec-ir.ts +689 -0
  66. package/src/spec-patches.ts +23 -0
  67. package/src/spec.ts +8 -0
  68. package/src/types.ts +53 -0
  69. package/src/unit.ts +15 -0
@@ -0,0 +1,691 @@
1
+ // THE SPEC IR — one representation of a vendor's published surface, beneath every spec format, that
2
+ // the derived-pack generator reads (docs/contributing/architecture.md, "Protocol 3"). A front end
3
+ // turns a spec into the IR; the generator only ever sees the IR. `fromOpenAPI` is the first front end, and `fromSmithy`
4
+ // reads an AWS Smithy JSON model
5
+ // (OpenAPI 3.x; Stripe's vendor extensions `x-resourceId` and `x-expandableFields` are read where
6
+ // present). Pure and deterministic: the same document always yields the same IR, byte for byte.
7
+ export const CRUD_CLASSES = new Set(['create', 'retrieve', 'list', 'update', 'delete']);
8
+ /** A schema's stated numeric bounds, when it states any. */
9
+ function boundsOf(doc, schema) {
10
+ const s = deref(doc, schema);
11
+ return { ...(typeof s?.minimum === 'number' ? { minimum: s.minimum } : {}), ...(typeof s?.maximum === 'number' ? { maximum: s.maximum } : {}) };
12
+ }
13
+ // every operation an OpenAPI path item may hold (a HEAD is as published as a GET: Vercel's /v8/artifacts/{hash})
14
+ const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
15
+ function deref(doc, node, depth = 0) {
16
+ if (!node || typeof node !== 'object' || depth > 8)
17
+ return node;
18
+ if (typeof node.$ref === 'string' && node.$ref.startsWith('#/')) {
19
+ const target = node.$ref.slice(2).split('/').reduce((acc, k) => (acc ? acc[k] : undefined), doc);
20
+ return deref(doc, target, depth + 1);
21
+ }
22
+ return node;
23
+ }
24
+ /** The fields a schema requires, its `allOf` members' included; a union requires what every alternative does. */
25
+ function requiredOf(doc, schema, depth = 0) {
26
+ const s = deref(doc, schema);
27
+ if (!s || typeof s !== 'object' || depth > 8)
28
+ return new Set();
29
+ const out = new Set(Array.isArray(s.required) ? s.required.map(String) : []);
30
+ for (const part of Array.isArray(s.allOf) ? s.allOf : [])
31
+ for (const f of requiredOf(doc, part, depth + 1))
32
+ out.add(f);
33
+ const alts = [...(Array.isArray(s.oneOf) ? s.oneOf : []), ...(Array.isArray(s.anyOf) ? s.anyOf : [])].map((a) => requiredOf(doc, a, depth + 1));
34
+ if (alts.length)
35
+ for (const f of alts[0])
36
+ if (alts.every((a) => a.has(f)))
37
+ out.add(f);
38
+ return out;
39
+ }
40
+ /** A schema's properties, with `allOf` members merged (how composed schemas are written). */
41
+ function propsOf(doc, schema, depth = 0) {
42
+ const s = deref(doc, schema);
43
+ if (!s || typeof s !== 'object' || depth > 8)
44
+ return {};
45
+ const merged = {};
46
+ for (const part of Array.isArray(s.allOf) ? s.allOf : [])
47
+ Object.assign(merged, propsOf(doc, part, depth + 1));
48
+ return { ...merged, ...(s.properties ?? {}) };
49
+ }
50
+ const DISCRIMINATOR = 'x-twin-discriminator';
51
+ /** The fields a resource can answer: its properties and, for a union (Slack's conversation is a oneOf
52
+ * of its kinds), every alternative's, since any of them may be what the vendor sends. */
53
+ function fieldsOf(doc, schema, depth = 0) {
54
+ const s = deref(doc, schema);
55
+ if (!s || typeof s !== 'object' || depth > 8)
56
+ return {};
57
+ const merged = {};
58
+ for (const alt of [...(Array.isArray(s.oneOf) ? s.oneOf : []), ...(Array.isArray(s.anyOf) ? s.anyOf : [])]) {
59
+ for (const [k, v] of Object.entries(fieldsOf(doc, alt, depth + 1))) {
60
+ if (!(k in merged)) {
61
+ merged[k] = v;
62
+ continue;
63
+ }
64
+ // a field several alternatives list values for (an input message's role, an output message's) takes them all
65
+ const had = deref(doc, merged[k]);
66
+ const more = deref(doc, v);
67
+ // (one value per alternative is the union's discriminator, Stripe's `object` or OpenAI's item `type`: which
68
+ // alternative this is, never a state)
69
+ if (Array.isArray(had?.enum) && Array.isArray(more?.enum)) {
70
+ const discriminates = (had.enum.length === 1 || had[DISCRIMINATOR] === true) && more.enum.length === 1;
71
+ merged[k] = { ...had, enum: [...new Set([...had.enum, ...more.enum])], ...(discriminates ? { [DISCRIMINATOR]: true } : {}) };
72
+ }
73
+ }
74
+ }
75
+ for (const part of Array.isArray(s.allOf) ? s.allOf : [])
76
+ Object.assign(merged, fieldsOf(doc, part, depth + 1));
77
+ return { ...merged, ...(s.properties ?? {}) };
78
+ }
79
+ // An object a spec writes inline where it answers a resource (hoistInline): its name, as a $ref would give one.
80
+ const inlineNames = new WeakMap();
81
+ const refName = (node) => typeof node?.$ref === 'string' ? node.$ref.split('/').pop() : node && typeof node === 'object' ? inlineNames.get(node) : undefined;
82
+ /** A spec that writes every schema in place (Tremendous: no $ref anywhere under paths, its components unused) names no
83
+ * resource. Each object an answer holds under one key (`{ order: {...} }`, `{ orders: [...] }`) with an `id` of its own is
84
+ * the resource that key names, singular (`order`): every inline copy of it is read under that name, and the item GET's
85
+ * copy (else a list's, else the first seen) is its schema. Each operation keeps its own copy for what it answers. A spec
86
+ * that references components anywhere under its paths is read as it always was. */
87
+ function hoistInline(doc) {
88
+ if (!doc?.paths || JSON.stringify(doc.paths).includes('"$ref"'))
89
+ return doc;
90
+ const out = structuredClone(doc);
91
+ const singular = (key) => (/ies$/.test(key) ? `${key.slice(0, -3)}y` : /(ses|xes)$/.test(key) ? key.slice(0, -2) : /s$/.test(key) ? key.slice(0, -1) : key);
92
+ const idBearing = (n) => !!n && typeof n === 'object' && !!n.properties?.id;
93
+ const chosen = new Map();
94
+ for (const [path, item] of Object.entries(out.paths)) {
95
+ for (const [method, op] of Object.entries(item ?? {})) {
96
+ if (!op || typeof op !== 'object' || !op.responses)
97
+ continue;
98
+ const code = Object.keys(op.responses).filter((c) => /^2\d\d$/.test(c)).sort()[0];
99
+ const schema = code ? op.responses[code]?.content?.['application/json']?.schema : undefined;
100
+ const props = schema?.properties;
101
+ if (!props || schema.properties.id)
102
+ continue;
103
+ const held = Object.entries(props).flatMap(([key, p]) => idBearing(p) ? [{ key, node: p, list: false }] : p?.type === 'array' && idBearing(p.items) ? [{ key: singular(key), node: p.items, list: true }] : []);
104
+ if (held.length !== 1)
105
+ continue;
106
+ const { key, node, list } = held[0];
107
+ inlineNames.set(node, key);
108
+ const rank = method === 'get' && !list && /\}$/.test(path) ? 0 : method === 'get' ? 1 : 2;
109
+ const had = chosen.get(key);
110
+ if (!had || rank < had.rank)
111
+ chosen.set(key, { node, rank });
112
+ }
113
+ }
114
+ if (!chosen.size)
115
+ return doc;
116
+ out.components = { ...(out.components ?? {}), schemas: { ...(out.components?.schemas ?? {}) } };
117
+ for (const [key, { node }] of chosen)
118
+ out.components.schemas[key] = node;
119
+ return out;
120
+ }
121
+ function typeOf(doc, schema) {
122
+ const s = deref(doc, schema);
123
+ if (!s || typeof s !== 'object')
124
+ return 'any';
125
+ if (refName(schema))
126
+ return refName(schema);
127
+ if (s.anyOf || s.oneOf)
128
+ return 'union';
129
+ return Array.isArray(s.type) ? s.type.join('|') : (s.type ?? (s.properties ? 'object' : 'any'));
130
+ }
131
+ export function slug(method, path) {
132
+ return `${method}_${path}`.replace(/[{}]/g, '').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
133
+ }
134
+ /** The resources a schema can be: one for a reference to a resource, several for a union of them
135
+ * (`anyOf: [customer, deleted_customer]`, `[card, bank_account, source]`), in the spec's order. */
136
+ function resourcesOfSchema(doc, schema, resources) {
137
+ const name = refName(schema);
138
+ if (name && resources.has(name))
139
+ return [resources.get(name)];
140
+ const s = deref(doc, schema);
141
+ for (const k of ['anyOf', 'oneOf', 'allOf']) {
142
+ if (Array.isArray(s?.[k]))
143
+ return [...new Set(s[k].flatMap((alt) => resourcesOfSchema(doc, alt, resources)))];
144
+ }
145
+ return [];
146
+ }
147
+ /** Whether a property of an envelope is its metadata, not what it holds: a list of messages (Cloudflare's `errors` and
148
+ * `messages`: `code`, `message`) or a paging block (`result_info`: `count`, `page`, `per_page`, `total_count`; cursors). */
149
+ const MESSAGE_FIELDS = new Set(['code', 'message', 'documentation_url', 'source', 'pointer']);
150
+ const PAGING_FIELDS = new Set(['count', 'page', 'per_page', 'total_count', 'total_pages', 'cursor', 'cursors', 'next', 'previous', 'next_cursor', 'before', 'after', 'has_more']);
151
+ function envelopeMeta(doc, node) {
152
+ const d = deref(doc, node);
153
+ const target = d?.type === 'array' ? d.items : node;
154
+ const keys = Object.keys(propsOf(doc, target));
155
+ return keys.length > 0 && (keys.every((k) => MESSAGE_FIELDS.has(k)) || keys.every((k) => PAGING_FIELDS.has(k)));
156
+ }
157
+ function successOf(doc, op, resources) {
158
+ const codes = Object.keys(op.responses ?? {}).filter((c) => /^2\d\d$/.test(c)).sort();
159
+ if (!codes.length)
160
+ return { status: 200 };
161
+ const status = Number(codes[0]);
162
+ const r = deref(doc, op.responses[codes[0]]);
163
+ // a success documented with no content at all answers no body, whatever its status (a 200 with nothing in it)
164
+ if (status !== 204 && (!r?.content || Object.keys(r.content).length === 0))
165
+ return { status, empty: true };
166
+ const schema = r?.content?.['application/json']?.schema;
167
+ if (!schema)
168
+ return { status };
169
+ const answer = (found, list, key) => ({ resource: found[0], list, ...(found.length > 1 ? { alternatives: found.slice(1) } : {}), ...(key ? { key } : {}) });
170
+ const direct = resourcesOfSchema(doc, schema, resources);
171
+ if (direct.length) {
172
+ // a named schema composed over the resource answers the resource's fields and its own
173
+ const composed = !resources.has(refName(schema) ?? '') && Array.isArray(deref(doc, schema)?.allOf);
174
+ const extra = composed ? Object.keys(deref(doc, schema).allOf.filter((part) => !resourcesOfSchema(doc, part, resources).length).reduce((all, part) => Object.assign(all, propsOf(doc, part)), {})) : [];
175
+ return { status, answers: { ...answer(direct, false), ...(extra.length ? { extends: extra.sort() } : {}) } };
176
+ }
177
+ const s = deref(doc, schema);
178
+ // A list: a bare array of a resource, or an envelope whose `data` is one (Stripe's list object).
179
+ const items = s?.type === 'array' ? s.items : s?.properties?.data?.type === 'array' ? s.properties.data.items : undefined;
180
+ const listed = items ? resourcesOfSchema(doc, items, resources) : [];
181
+ if (listed.length)
182
+ return { status, answers: answer(listed, true) };
183
+ // An envelope that holds exactly one resource, or one list of them, under a named property, beside
184
+ // at most a few scalars (`ok`, a cursor). An object with an `id` of its own, or with other objects
185
+ // in it, is not an envelope: it is something that embeds another (a Pages site, a branch).
186
+ const own = propsOf(doc, schema);
187
+ if (own.id)
188
+ return { status };
189
+ // objects and arrays beside the held one: a paging block is fine, a record's own sub-objects are not
190
+ const objectish = Object.values(own).filter((p) => { const d = deref(doc, p); return d && typeof d === 'object' && (d.type === 'object' || d.type === 'array' || !!d.properties || !!d.allOf) && !envelopeMeta(doc, p); });
191
+ if (objectish.length > 3 || Object.keys(own).length > 6)
192
+ return { status };
193
+ const held = Object.entries(propsOf(doc, schema)).filter(([, p]) => !envelopeMeta(doc, p)).flatMap(([key, p]) => {
194
+ const one = resourcesOfSchema(doc, p, resources);
195
+ if (one.length)
196
+ return [{ key, found: one, list: false, node: p }];
197
+ const d = deref(doc, p);
198
+ const many = d?.type === 'array' ? resourcesOfSchema(doc, d.items, resources) : [];
199
+ return many.length ? [{ key, found: many, list: true, node: d.items }] : [];
200
+ });
201
+ if (held.length !== 1)
202
+ return { status };
203
+ const h = held[0];
204
+ // an operation's own inline copy of a resource (hoistInline) that answers fields the resource's schema lacks
205
+ // (Tremendous's generate-reward-link: { id, link }) answers them beside the resource's
206
+ const name = h.node && typeof h.node === 'object' ? inlineNames.get(h.node) : undefined;
207
+ const named = name ? doc?.components?.schemas?.[name] : undefined;
208
+ const extra = named && named !== h.node ? Object.keys(propsOf(doc, h.node)).filter((f) => !(f in propsOf(doc, named))).sort() : [];
209
+ return { status, answers: { ...answer(h.found, h.list, h.key), ...(extra.length ? { extends: extra } : {}) } };
210
+ }
211
+ function paramsOf(doc, op, pathItem) {
212
+ const all = [...(pathItem.parameters ?? []), ...(op.parameters ?? [])].map((p) => deref(doc, p));
213
+ const pathParams = all.filter((p) => p.in === 'path').map((p) => String(p.name));
214
+ const query = all
215
+ .filter((p) => p.in === 'query')
216
+ .map((p) => ({ name: String(p.name), type: typeOf(doc, p.schema), required: p.required === true, ...boundsOf(doc, p.schema) }))
217
+ .sort((a, b) => a.name.localeCompare(b.name));
218
+ return { pathParams, query };
219
+ }
220
+ function bodyOf(doc, op) {
221
+ const content = deref(doc, op.requestBody)?.content ?? {};
222
+ const mediaOf = { json: 'application/json', form: 'application/x-www-form-urlencoded', multipart: 'multipart/form-data' };
223
+ const encoding = content[mediaOf.json] ? 'json' : content[mediaOf.form] ? 'form' : content[mediaOf.multipart] ? 'multipart' : 'none';
224
+ if (encoding === 'none')
225
+ return { body: [], bodyEncoding: 'none' };
226
+ const schema = deref(doc, content[mediaOf[encoding]].schema);
227
+ const required = new Set(schema?.required ?? []);
228
+ const body = Object.entries(schema?.properties ?? {})
229
+ .map(([name, p]) => ({ name, type: typeOf(doc, p), required: required.has(name), ...boundsOf(doc, p) }))
230
+ .sort((a, b) => a.name.localeCompare(b.name));
231
+ return { body, bodyEncoding: encoding };
232
+ }
233
+ /** A form-encoded operation's fields whose value is a number or a boolean, by bracket path: a form carries only text, so
234
+ * the spec's type is what says `unit_amount=2400` is a number and `metadata[order]=007` or `name=2024` is text. A path
235
+ * segment is a property name, `[]` for an array's item, or `*` for a map's key (`additionalProperties`). A field some
236
+ * alternative of whose union takes free text is text; `""`, the value Stripe's unsettable fields take, stays text
237
+ * wherever it is sent. */
238
+ function formScalarsOf(doc, op, pathItem, encoding) {
239
+ if (encoding !== 'form')
240
+ return undefined;
241
+ const out = {};
242
+ const visit = (schema, at, depth) => {
243
+ const s = deref(doc, schema);
244
+ if (!s || typeof s !== 'object' || depth > 12)
245
+ return;
246
+ const alts = [...(Array.isArray(s.anyOf) ? s.anyOf : []), ...(Array.isArray(s.oneOf) ? s.oneOf : [])].map((a) => deref(doc, a));
247
+ if (alts.length) {
248
+ const scalar = alts.map((a) => a?.type).find((t) => t === 'integer' || t === 'number' || t === 'boolean');
249
+ const freeText = alts.some((a) => a?.type === 'string' && !Array.isArray(a.enum));
250
+ if (scalar && !freeText && at.length)
251
+ out[at.join('.')] = scalar;
252
+ for (const a of alts)
253
+ if (a?.type !== 'integer' && a?.type !== 'number' && a?.type !== 'boolean')
254
+ visit(a, at, depth + 1);
255
+ return;
256
+ }
257
+ if ((s.type === 'integer' || s.type === 'number' || s.type === 'boolean') && at.length) {
258
+ out[at.join('.')] = s.type;
259
+ return;
260
+ }
261
+ if (s.type === 'array') {
262
+ visit(s.items, [...at, '[]'], depth + 1);
263
+ return;
264
+ }
265
+ for (const [name, p] of Object.entries(propsOf(doc, s)))
266
+ visit(p, [...at, name], depth + 1);
267
+ if (s.additionalProperties && typeof s.additionalProperties === 'object')
268
+ visit(s.additionalProperties, [...at, '*'], depth + 1);
269
+ };
270
+ for (const p of [...(pathItem.parameters ?? []), ...(op.parameters ?? [])].map((q) => deref(doc, q)))
271
+ if (p?.in === 'query')
272
+ visit(p.schema, [String(p.name)], 0);
273
+ const body = deref(doc, deref(doc, op.requestBody)?.content?.['application/x-www-form-urlencoded']?.schema);
274
+ for (const [name, p] of Object.entries(propsOf(doc, body)))
275
+ visit(p, [name], 0);
276
+ return Object.keys(out).length ? Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b))) : undefined;
277
+ }
278
+ /** The class of one operation, from its method, its path and what it answers. An operation this cannot place is `action`, never CRUD. */
279
+ /** A path and its discriminator as one key: `/responses/{id}` and `/responses/{id}?beta=true` are different reads. */
280
+ // a null value (the key present with any value) keys as the bare name
281
+ const keyOf = (path, discriminator) => (discriminator ? `${path}?${new URLSearchParams(Object.entries(discriminator).map(([k, v]) => [k, v ?? '']))}` : path);
282
+ /** An RPC method's class from its verb (`conversations.list`, `chat.postMessage`): the conventions
283
+ * RPC-style vendors share. An unfamiliar verb is an action. */
284
+ function rpcClass(verb, answersList) {
285
+ const v = verb.toLowerCase();
286
+ if (answersList || /^(list|history|replies|members)$/.test(v))
287
+ return 'list';
288
+ if (/^(info|get|lookup|read)/.test(v))
289
+ return 'retrieve';
290
+ if (/^(create|add|open|post|schedule|upload)/.test(v))
291
+ return 'create';
292
+ if (/^(update|edit|rename|set)/.test(v))
293
+ return 'update';
294
+ if (/^(delete|remove)/.test(v))
295
+ return 'delete';
296
+ return 'action';
297
+ }
298
+ function classify(o, itemGets, listGets, singletons, settingOf = new Map()) {
299
+ const segs = o.path.split('/').filter(Boolean);
300
+ const last = segs[segs.length - 1] ?? '';
301
+ if (segs.length === 1 && last.includes('.')) {
302
+ const verb = last.slice(last.lastIndexOf('.') + 1);
303
+ let cls = rpcClass(verb, o.answers?.list === true);
304
+ // a method the vendor documents as a GET is a read, whatever its verb (`auth.test`, `search.messages`)
305
+ if (o.method === 'get' && !['retrieve', 'list'].includes(cls))
306
+ cls = o.answers?.list ? 'list' : o.answers ? 'retrieve' : 'computed';
307
+ return { class: cls, ...(o.answers ? { resource: o.answers.resource } : {}) };
308
+ }
309
+ const lastIsParam = last.startsWith('{');
310
+ const here = itemGets.get(keyOf(o.path, o.discriminator));
311
+ const parentPath = '/' + segs.slice(0, -1).join('/');
312
+ const parent = itemGets.get(keyOf(parentPath, o.discriminator));
313
+ const answered = o.answers?.resource;
314
+ const setting = settingOf.get(keyOf(o.path, o.discriminator));
315
+ if (o.method === 'get') {
316
+ if (setting)
317
+ return { class: 'retrieve', resource: setting };
318
+ if (last === 'search')
319
+ return { class: 'computed', resource: answered };
320
+ if (o.answers?.list)
321
+ return { class: 'list', resource: answered };
322
+ if (answered && lastIsParam)
323
+ return { class: 'retrieve', resource: answered };
324
+ if (answered && here === answered)
325
+ return { class: 'retrieve', resource: answered };
326
+ return { class: 'computed', resource: answered ?? parent };
327
+ }
328
+ if (o.method === 'delete')
329
+ return lastIsParam || here ? { class: 'delete', resource: here ?? answered } : { class: 'action', resource: parent ?? answered };
330
+ if (lastIsParam && answered && here === answered)
331
+ return { class: 'update', resource: answered };
332
+ if (o.method === 'post' && !lastIsParam && answered && [...itemGets].some(([k, r]) => r === answered && k.startsWith(`${o.path}/{`) && k.split('?')[0].split('/').length === segs.length + 2)) {
333
+ return { class: 'create', resource: answered };
334
+ }
335
+ // a POST to a collection answering one of its items creates it, even when the item is read back
336
+ // elsewhere (GitHub reads an issue comment at /issues/comments/{id}, not under its issue)
337
+ if (o.method === 'post' && answered && listGets.get(keyOf(o.path, o.discriminator)) === answered)
338
+ return { class: 'create', resource: answered };
339
+ // a setting read and written at one path (an id-less object whose identity is its path)
340
+ if (singletons.has(keyOf(o.path, o.discriminator)) && (o.method === 'put' || o.method === 'patch'))
341
+ return { class: 'update', ...(setting ?? answered ? { resource: (setting ?? answered) } : {}) };
342
+ if (singletons.has(keyOf(o.path, o.discriminator)) && o.method === 'delete')
343
+ return { class: 'delete', ...(setting ?? answered ? { resource: (setting ?? answered) } : {}) };
344
+ if (!answered && !parent)
345
+ return { class: 'non-resource' };
346
+ return { class: 'action', resource: parent ?? answered };
347
+ }
348
+ export function fromOpenAPI(spec) {
349
+ const doc = hoistInline(spec);
350
+ const schemas = doc?.components?.schemas ?? {};
351
+ // A resource is a schema the vendor declares one (`x-resourceId`), or, lacking any declaration,
352
+ // a schema with an `id` that some GET answers or lists: something a caller can read back.
353
+ const declared = Object.entries(schemas).filter(([, s]) => typeof s?.['x-resourceId'] === 'string');
354
+ const resourceBySchema = new Map(declared.map(([n, s]) => [n, String(s['x-resourceId'])]));
355
+ if (!declared.length) {
356
+ // what a GET reads back: an id-bearing schema it answers directly, the items of a list it
357
+ // answers, or the one object an envelope it answers holds (`{ ok, channel }`); identity need
358
+ // not be an `id` (a Slack message is its channel and `ts`)
359
+ // a map (an object whose keys are the caller's data: `additionalProperties` and no properties of its own, as
360
+ // currencyapi's `RatesMap` keyed by currency code) is not a thing a caller reads back: its keys are values, not fields
361
+ const pureMap = (d) => !!d.additionalProperties && typeof d.additionalProperties === 'object' && !d.properties && !d.allOf && !d.anyOf && !d.oneOf;
362
+ const objectLike = (node) => {
363
+ const d = deref(doc, node);
364
+ return !!d && typeof d === 'object' && !pureMap(d) && (d.type === 'object' || !!d.properties || !!d.allOf || !!d.anyOf || !!d.oneOf);
365
+ };
366
+ const named = (node) => {
367
+ const name = refName(node);
368
+ if (name && schemas[name] && objectLike(node))
369
+ return [name];
370
+ const d = deref(doc, node);
371
+ for (const k of ['anyOf', 'oneOf'])
372
+ if (Array.isArray(d?.[k]))
373
+ return d[k].flatMap(named);
374
+ // an inline object that is one named schema (`{ type: object, allOf: [$ref] }`: Cloudflare's custom hostname)
375
+ if (Array.isArray(d?.allOf) && d.allOf.length === 1)
376
+ return named(d.allOf[0]);
377
+ return [];
378
+ };
379
+ const readBack = (schema) => {
380
+ const name = refName(schema);
381
+ if (name && schemas[name] && propsOf(doc, schema).id)
382
+ return [name];
383
+ const d = deref(doc, schema);
384
+ if (d?.type === 'array')
385
+ return named(d.items);
386
+ const props = propsOf(doc, schema);
387
+ if (props.data && deref(doc, props.data)?.type === 'array')
388
+ return named(deref(doc, props.data).items);
389
+ const held = Object.values(props).filter((p) => !envelopeMeta(doc, p)).map((p) => (deref(doc, p)?.type === 'array' ? named(deref(doc, p).items) : named(p))).filter((n) => n.length);
390
+ return held.length === 1 ? held[0] : [];
391
+ };
392
+ for (const item of Object.values(doc.paths ?? {})) {
393
+ const get = item?.get;
394
+ const code = Object.keys(get?.responses ?? {}).filter((c) => /^2\d\d$/.test(c)).sort()[0];
395
+ const schema = code ? deref(doc, get.responses[code])?.content?.['application/json']?.schema : undefined;
396
+ for (const r of schema ? readBack(schema) : [])
397
+ resourceBySchema.set(r, r);
398
+ }
399
+ }
400
+ const server = String(doc?.servers?.[0]?.url ?? '');
401
+ const docBase = /^https?:\/\//.test(server) ? new URL(server).pathname.replace(/\/+$/, '') : '';
402
+ const raw = [];
403
+ for (const [key, item] of Object.entries(doc.paths ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
404
+ const [path, query] = key.split('?');
405
+ const discriminator = query ? Object.fromEntries(new URLSearchParams(query)) : undefined;
406
+ for (const method of METHODS) {
407
+ const op = item[method];
408
+ if (!op)
409
+ continue;
410
+ const success = successOf(doc, op, resourceBySchema);
411
+ const streams = Object.entries(op.responses ?? {}).some(([code, r]) => /^2\d\d$/.test(code) && deref(doc, r)?.content?.['text/event-stream']);
412
+ const security = (op.security ?? doc.security ?? []);
413
+ const scopes = [...new Set(security.flatMap((req) => Object.values(req ?? {}).flat()))].sort();
414
+ const credentials = [...new Set(security.flatMap((req) => { const names = Object.keys(req ?? {}); return names.length ? names : ['anonymous']; }))].sort();
415
+ const body = bodyOf(doc, op);
416
+ const scalars = formScalarsOf(doc, op, item, body.bodyEncoding);
417
+ const ownServer = String((op.servers ?? item.servers)?.[0]?.url ?? '');
418
+ const ownBase = /^https?:\/\//.test(ownServer) ? new URL(ownServer).pathname.replace(/\/+$/, '') : undefined;
419
+ raw.push({
420
+ id: typeof op.operationId === 'string' && op.operationId ? op.operationId : slug(method, key),
421
+ method,
422
+ path,
423
+ ...(discriminator ? { discriminator } : {}),
424
+ ...paramsOf(doc, op, item),
425
+ ...(ownBase !== undefined && ownBase !== docBase ? { basePath: ownBase } : {}),
426
+ ...body,
427
+ ...(scalars ? { scalars } : {}),
428
+ successStatus: success.status,
429
+ ...(success.empty ? { emptySuccess: true } : {}),
430
+ ...(streams ? { streams: true } : {}),
431
+ ...(scopes.length ? { scopes } : {}),
432
+ ...(credentials.length && !(credentials.length === 1 && credentials[0] === 'anonymous') ? { credentials } : {}),
433
+ ...(success.answers ? { answers: success.answers } : {}),
434
+ });
435
+ }
436
+ }
437
+ // Paths whose GET retrieves one resource: what a write at that path, or under it, acts on.
438
+ const itemGets = new Map();
439
+ for (const o of raw)
440
+ if (o.method === 'get' && o.answers && !o.answers.list)
441
+ itemGets.set(keyOf(o.path, o.discriminator), o.answers.resource);
442
+ const listGets = new Map();
443
+ for (const o of raw)
444
+ if (o.method === 'get' && o.answers?.list)
445
+ listGets.set(keyOf(o.path, o.discriminator), o.answers.resource);
446
+ const singletons = new Set();
447
+ for (const o of raw)
448
+ if (o.method === 'get' && !o.answers && !o.path.split('/').at(-1).startsWith('{') && o.successStatus === 200)
449
+ singletons.add(keyOf(o.path, o.discriminator));
450
+ // a setting's resource: the named schema its GET answers (`AuthConfigResponse`), which a pack may declare to have the
451
+ // core read it (its defaults until first written) and write it (a merge that creates it), keyed by the path's parent
452
+ const settingOf = new Map();
453
+ for (const [key, item] of Object.entries(doc.paths ?? {})) {
454
+ const get = item.get;
455
+ const [path, query] = key.split('?');
456
+ const k = keyOf(path, query ? Object.fromEntries(new URLSearchParams(query)) : undefined);
457
+ if (!get || !singletons.has(k))
458
+ continue;
459
+ const schema = deref(doc, get.responses?.['200'])?.content?.['application/json']?.schema;
460
+ const name = schema ? refName(schema) : undefined;
461
+ if (name && deref(doc, schema)?.type !== 'array')
462
+ settingOf.set(k, name);
463
+ }
464
+ let operations = raw.map((o) => {
465
+ const c = classify(o, itemGets, listGets, singletons, settingOf);
466
+ return { ...o, class: c.class, ...(c.resource ? { resource: c.resource } : {}) };
467
+ });
468
+ // An RPC method that answers no resource acts on its family's: the one the family's retrieve reads
469
+ // (`conversations.archive` acts on what `conversations.info` answers).
470
+ const familyOf = (path) => (/^\/[^/]+\.[^/]+$/.test(path) ? path.slice(1, path.lastIndexOf('.')) : undefined);
471
+ const familyResource = new Map();
472
+ for (const o of operations)
473
+ if (o.class === 'retrieve' && o.resource && familyOf(o.path))
474
+ familyResource.set(familyOf(o.path), o.resource);
475
+ operations = operations.map((o) => (o.resource || !familyOf(o.path) || !familyResource.has(familyOf(o.path)) ? o : { ...o, resource: familyResource.get(familyOf(o.path)) }));
476
+ const resources = [...resourceBySchema]
477
+ .map(([schemaName, name]) => {
478
+ const s = deref(doc, schemas[schemaName]);
479
+ const required = requiredOf(doc, schemas[schemaName]);
480
+ const fields = Object.entries(fieldsOf(doc, schemas[schemaName]))
481
+ .map(([f, p]) => {
482
+ const d = deref(doc, p);
483
+ return { name: f, type: typeOf(doc, p), nullable: d?.nullable === true, ...(required.has(f) ? { required: true } : {}), ...(Array.isArray(d?.enum) ? { enum: d.enum.map(String) } : {}), ...(d?.[DISCRIMINATOR] ? { discriminator: true } : {}) };
484
+ })
485
+ .sort((a, b) => a.name.localeCompare(b.name));
486
+ return {
487
+ name,
488
+ schema: schemaName,
489
+ fields,
490
+ // an enum with several values, and the fields a lifecycle usually lives in though the spec types
491
+ // them plainly (a string `status`, a boolean `merged`): the manifest rules on every one
492
+ stateCandidates: fields
493
+ .filter((f) => !f.discriminator)
494
+ .filter((f) => (f.enum && f.enum.length > 1) || (f.type === 'string' && /^(status|state|conclusion)$/.test(f.name)) || (f.type === 'boolean' && /^(merged|draft|locked|archived|closed|captured|paid|refunded|disabled|active|livemode_off|is_[a-z_]+)$/.test(f.name)))
495
+ .map((f) => ({ field: f.name, values: f.enum ? [...f.enum].sort() : f.type === 'boolean' ? ['false', 'true'] : [] })),
496
+ expandable: Array.isArray(s?.['x-expandableFields']) ? [...s['x-expandableFields']].map(String).sort() : [],
497
+ };
498
+ })
499
+ .sort((a, b) => a.name.localeCompare(b.name) || a.schema.localeCompare(b.schema));
500
+ const basePath = docBase;
501
+ return { format: 'openapi', version: String(doc?.info?.version ?? ''), ...(basePath ? { basePath } : {}), resources, operations };
502
+ }
503
+ /** A Swagger 2.0 document as the OpenAPI 3 shape `fromOpenAPI` reads: definitions become component
504
+ * schemas, formData and body parameters a request body, response schemas JSON content, and
505
+ * host + basePath the server. Nothing vendor-specific. */
506
+ export function fromSwagger2(doc) {
507
+ const rewrite = (node) => {
508
+ if (Array.isArray(node))
509
+ return node.map(rewrite);
510
+ if (!node || typeof node !== 'object')
511
+ return node;
512
+ const out = {};
513
+ for (const [k, v] of Object.entries(node))
514
+ out[k] = k === '$ref' && typeof v === 'string' ? v.replace('#/definitions/', '#/components/schemas/') : rewrite(v);
515
+ return out;
516
+ };
517
+ const paths = {};
518
+ for (const [key, item] of Object.entries(doc.paths ?? {})) {
519
+ const next = {};
520
+ for (const method of METHODS) {
521
+ const op = item[method];
522
+ if (!op)
523
+ continue;
524
+ const params = [...(item.parameters ?? []), ...(op.parameters ?? [])];
525
+ const form = params.filter((p) => p.in === 'formData');
526
+ const body = params.find((p) => p.in === 'body');
527
+ const consumes = op.consumes ?? doc.consumes ?? [];
528
+ const formType = consumes.includes('multipart/form-data') ? 'multipart/form-data' : 'application/x-www-form-urlencoded';
529
+ const requestBody = body
530
+ ? { content: { 'application/json': { schema: body.schema } } }
531
+ : form.length
532
+ ? { content: { [formType]: { schema: { type: 'object', required: form.filter((p) => p.required).map((p) => p.name), properties: Object.fromEntries(form.map((p) => [p.name, { type: p.type ?? 'string' }])) } } } }
533
+ : undefined;
534
+ next[method] = {
535
+ ...op,
536
+ parameters: params.filter((p) => p.in === 'path' || p.in === 'query').map((p) => ({ name: p.name, in: p.in, required: p.required, schema: { type: p.type ?? 'string' } })),
537
+ ...(requestBody ? { requestBody } : {}),
538
+ responses: Object.fromEntries(Object.entries(op.responses ?? {}).map(([code, r]) => [code, { description: r?.description ?? '', ...(r?.schema ? { content: { 'application/json': { schema: r.schema } } } : {}) }])),
539
+ };
540
+ }
541
+ paths[key] = next;
542
+ }
543
+ const scheme = (doc.schemes ?? ['https'])[0];
544
+ return fromOpenAPI(rewrite({
545
+ openapi: '3.0.0',
546
+ info: doc.info,
547
+ ...(doc.host ? { servers: [{ url: `${scheme}://${doc.host}${doc.basePath ?? ''}` }] } : {}),
548
+ ...(doc.security ? { security: doc.security } : {}),
549
+ components: { schemas: doc.definitions ?? {} },
550
+ paths,
551
+ }));
552
+ }
553
+ // ── Smithy (AWS) ─────────────────────────────────────────────────────────────────────────────
554
+ // An AWS service's Smithy JSON model: its operations, each named on the wire by `X-Amz-Target: <prefix>.<Operation>`
555
+ // (awsJson1_0 / awsJson1_1, every operation a POST to `/`) or by its `smithy.api#http` trait (restJson1, restXml). A model
556
+ // with no resource shapes (Secrets Manager) answers each operation with its output structure, which is the resource
557
+ // that operation answers: its members are the fields, `smithy.api#required` marks the required ones, and an enum
558
+ // shape's values are the field's values. Timestamps are numbers on AWS JSON (epoch seconds).
559
+ const short = (id) => id.split('#').pop();
560
+ /** An AWS service's Smithy JSON model as the IR (`service` names the service shape when the model holds several). */
561
+ export function fromSmithy(model, service) {
562
+ const shapes = model.shapes ?? {};
563
+ const serviceId = service ?? Object.keys(shapes).find((k) => shapes[k].type === 'service');
564
+ const svc = shapes[serviceId];
565
+ const traits = svc.traits ?? {};
566
+ const json = Boolean(traits['aws.protocols#awsJson1_0'] || traits['aws.protocols#awsJson1_1']);
567
+ // AWS JSON names an operation by the service shape's own name (secretsmanager.CreateSecret, DynamoDB_20120810.PutItem)
568
+ const targetPrefix = short(serviceId);
569
+ const typeOfShape = (target) => {
570
+ const s = shapes[target];
571
+ if (!s)
572
+ return short(target).toLowerCase();
573
+ if (s.type === 'structure' || s.type === 'union')
574
+ return short(target);
575
+ if (s.type === 'timestamp')
576
+ return json ? 'number' : 'string';
577
+ if (s.type === 'enum' || s.type === 'intEnum')
578
+ return 'string';
579
+ return s.type;
580
+ };
581
+ const enumOf = (target) => {
582
+ const s = shapes[target];
583
+ if (!s)
584
+ return undefined;
585
+ if (s.type === 'enum')
586
+ return Object.entries(s.members ?? {}).map(([k, m]) => String(m.traits?.['smithy.api#enumValue'] ?? k));
587
+ if (Array.isArray(s.traits?.['smithy.api#enum']))
588
+ return s.traits['smithy.api#enum'].map((e) => String(e.value));
589
+ return undefined;
590
+ };
591
+ const resources = new Map();
592
+ const xmlWire = Boolean(traits['aws.protocols#restXml']);
593
+ const resourceOf = (target) => {
594
+ const name = short(target);
595
+ if (resources.has(name))
596
+ return name;
597
+ const s = shapes[target] ?? {};
598
+ // on restXml an output's members bound to headers or the status are not in its body, and an element is named by its
599
+ // xmlName where it has one (what SHAPE sees of the answer)
600
+ const inBody = ([, m]) => !xmlWire || !Object.keys(m.traits ?? {}).some((t) => /^smithy\.api#http(Header|PrefixHeaders|ResponseCode)$/.test(t));
601
+ const fields = Object.entries(s.members ?? {}).filter(inBody).map(([f, m]) => {
602
+ const values = enumOf(m.target);
603
+ return { name: xmlWire ? String(m.traits?.['smithy.api#xmlName'] ?? f) : f, type: typeOfShape(m.target), nullable: !m.traits?.['smithy.api#required'], ...(m.traits?.['smithy.api#required'] ? { required: true } : {}), ...(values ? { enum: values } : {}) };
604
+ }).sort((a, b) => a.name.localeCompare(b.name));
605
+ const stateCandidates = fields.filter((f) => f.enum && f.enum.length > 1).map((f) => ({ field: f.name, values: [...f.enum].sort() }));
606
+ resources.set(name, { name, schema: name, fields, stateCandidates, expandable: [] });
607
+ return name;
608
+ };
609
+ const classOf = (name) => {
610
+ if (/^Create/.test(name))
611
+ return 'create';
612
+ if (/^(Get|Describe)/.test(name))
613
+ return 'retrieve';
614
+ if (/^List/.test(name))
615
+ return 'list';
616
+ if (/^(Update|Put)/.test(name))
617
+ return 'update';
618
+ if (/^Delete/.test(name))
619
+ return 'delete';
620
+ return 'action';
621
+ };
622
+ // an HTTP-bound protocol (restJson1, restXml) binds each input member to the wire by a trait: a path label, a query
623
+ // key, a header, or the payload (smithy.io/2.0/spec/http-bindings); what no trait binds is the body. The IR carries
624
+ // the labels, the query and the body; a header-bound member is the handler's to read.
625
+ // what an operation's success answer is: its output structure, or on restXml the structure its payload member holds
626
+ // (GetBucketCors answers its CORSConfiguration); a payload of bytes (GetObject's Body) is no resource
627
+ const answerOf = (op) => {
628
+ if (!op.output)
629
+ return undefined;
630
+ const payload = Object.values((shapes[op.output.target]?.members ?? {})).find((m) => m.traits?.['smithy.api#httpPayload'] !== undefined);
631
+ if (!xmlWire || !payload)
632
+ return resourceOf(op.output.target);
633
+ return shapes[payload.target]?.type === 'structure' ? resourceOf(payload.target) : undefined;
634
+ };
635
+ const BOUND = /^smithy\.api#http(Label|Query|QueryParams|Header|PrefixHeaders|ResponseCode)$/;
636
+ const operations = (svc.operations ?? []).map((o) => o.target).sort().map((target) => {
637
+ const op = shapes[target];
638
+ const name = short(target);
639
+ const http = op.traits?.['smithy.api#http'];
640
+ const input = op.input ? shapes[op.input.target] : undefined;
641
+ const members = Object.entries(input?.members ?? {});
642
+ const param = ([f, m]) => ({ name: f, type: typeOfShape(m.target), required: Boolean(m.traits?.['smithy.api#required']) });
643
+ // a uri's query literals (`/{Bucket}?cors`) must be carried by a request to match it, and tell operations at one
644
+ // path apart; a greedy label (`{Key+}`) is the label, the surface's `spanning` naming it
645
+ const [uriPath, uriQuery] = String(http?.uri ?? '/').split('?');
646
+ const discriminator = !json && uriQuery ? Object.fromEntries(uriQuery.split('&').map((kv) => { const [k, v = ''] = kv.split('='); return [k, v]; })) : undefined;
647
+ const payload = members.find(([, m]) => m.traits?.['smithy.api#httpPayload'] !== undefined);
648
+ return {
649
+ id: name,
650
+ method: json ? 'post' : String(http?.method ?? 'POST').toLowerCase(),
651
+ path: json ? '/' : uriPath.replace(/\{([^}+]+)\+\}/g, '{$1}'),
652
+ ...(discriminator ? { discriminator } : {}),
653
+ ...(json ? { headers: { 'x-amz-target': `${targetPrefix}.${name}` } } : {}),
654
+ pathParams: json ? [] : [...uriPath.matchAll(/\{([^}+]+)\+?\}/g)].map((m) => m[1]),
655
+ query: json ? [] : members.filter(([, m]) => m.traits?.['smithy.api#httpQuery'] !== undefined).map(([f, m]) => ({ ...param([f, m]), name: String(m.traits['smithy.api#httpQuery']) })),
656
+ body: json ? members.map(param) : payload ? [param(payload)] : members.filter(([, m]) => !Object.keys(m.traits ?? {}).some((t) => BOUND.test(t))).map(param),
657
+ bodyEncoding: xmlWire ? 'xml' : 'json',
658
+ successStatus: json ? 200 : Number(http?.code ?? 200),
659
+ ...(answerOf(op) ? { answers: { resource: answerOf(op), list: false } } : {}),
660
+ class: classOf(name),
661
+ };
662
+ });
663
+ // operations at one method, path and query literals are told apart, as the service tells them, by what each requires
664
+ // the request to carry: a required query key or header present (S3: CopyObject's `x-amz-copy-source`, UploadPart's
665
+ // `partNumber` and `uploadId`); an operation that requires none is the one a request carrying none of them reaches
666
+ const at = new Map();
667
+ for (const o of operations) {
668
+ const k = `${o.method} ${o.path} ${JSON.stringify(o.discriminator ?? {})}`;
669
+ at.set(k, [...(at.get(k) ?? []), o]);
670
+ }
671
+ for (const group of at.values()) {
672
+ if (json || group.length < 2)
673
+ continue;
674
+ for (const o of group) {
675
+ const input = shapes[shapes[`${serviceId.split('#')[0]}#${o.id}`]?.input?.target ?? ''];
676
+ for (const m of Object.values((input?.members ?? {}))) {
677
+ if (!m.traits?.['smithy.api#required'])
678
+ continue;
679
+ const q = m.traits['smithy.api#httpQuery'];
680
+ const h = m.traits['smithy.api#httpHeader'];
681
+ if (q !== undefined)
682
+ o.discriminator = { ...(o.discriminator ?? {}), [String(q)]: o.discriminator?.[String(q)] ?? null };
683
+ if (h !== undefined)
684
+ o.headers = { ...(o.headers ?? {}), [String(h).toLowerCase()]: null };
685
+ }
686
+ }
687
+ }
688
+ // "a label suffixed with the + qualifier that can be used to match more than one path segment" (smithy.io/2.0/spec/http-bindings.html, Greedy labels)
689
+ const spanning = [...new Set((svc.operations ?? []).flatMap((o) => [...String(shapes[o.target]?.traits?.['smithy.api#http']?.uri ?? '').matchAll(/\{([^}+]+)\+\}/g)].map((m) => m[1])))].sort();
690
+ return { format: 'smithy', version: String(svc.version ?? ''), ...(spanning.length ? { spanning } : {}), resources: [...resources.values()].sort((a, b) => a.name.localeCompare(b.name)), operations };
691
+ }