@voxgig/apidef 8.15.2 → 8.16.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 (45) hide show
  1. package/dist/builder/entity/entity.js +8 -5
  2. package/dist/builder/entity/entity.js.map +1 -1
  3. package/dist/builder/entity/info.js +1 -1
  4. package/dist/builder/entity/info.js.map +1 -1
  5. package/dist/builder/flow.js +3 -3
  6. package/dist/builder/flow.js.map +1 -1
  7. package/dist/cli.js +8 -10
  8. package/dist/cli.js.map +1 -1
  9. package/dist/guide/guide.d.ts +14 -1
  10. package/dist/guide/guide.js +104 -62
  11. package/dist/guide/guide.js.map +1 -1
  12. package/dist/guide/heuristic01.js +105 -34
  13. package/dist/guide/heuristic01.js.map +1 -1
  14. package/dist/parse.d.ts +2 -1
  15. package/dist/parse.js +42 -43
  16. package/dist/parse.js.map +1 -1
  17. package/dist/refcount.d.ts +6 -0
  18. package/dist/refcount.js +215 -0
  19. package/dist/refcount.js.map +1 -0
  20. package/dist/transform/field.js +1 -1
  21. package/dist/transform/field.js.map +1 -1
  22. package/dist/transform/operation.js +1 -1
  23. package/dist/transform/operation.js.map +1 -1
  24. package/dist/transform/top.js +1 -1
  25. package/dist/transform/top.js.map +1 -1
  26. package/dist/tsconfig.tsbuildinfo +1 -1
  27. package/dist/utility.d.ts +2 -1
  28. package/dist/utility.js +42 -1
  29. package/dist/utility.js.map +1 -1
  30. package/model/{guide.aon → guide.aontu} +1 -1
  31. package/package.json +3 -3
  32. package/src/builder/entity/entity.ts +8 -5
  33. package/src/builder/entity/info.ts +1 -1
  34. package/src/builder/flow.ts +3 -3
  35. package/src/cli.ts +8 -10
  36. package/src/guide/guide.ts +133 -71
  37. package/src/guide/heuristic01.ts +136 -38
  38. package/src/parse.ts +64 -38
  39. package/src/refcount.ts +244 -0
  40. package/src/transform/field.ts +1 -1
  41. package/src/transform/operation.ts +1 -1
  42. package/src/transform/top.ts +1 -1
  43. package/src/types.ts +2 -2
  44. package/src/utility.ts +54 -1
  45. /package/model/{apidef.aon → apidef.aontu} +0 -0
package/src/parse.ts CHANGED
@@ -132,6 +132,16 @@ async function parseOpenAPI(source: any, _meta?: any) {
132
132
  }
133
133
 
134
134
 
135
+ // Edges decycle cut, so the guide can still count through them; a clone has none.
136
+ const DECYCLED = new WeakMap<object, Map<string, object>>()
137
+
138
+
139
+ function decycledChild(holder: any, key: string | number): any {
140
+ const cut = DECYCLED.get(holder)?.get(String(key))
141
+ return undefined === cut ? holder[key] : cut
142
+ }
143
+
144
+
135
145
  function decycle(root: any) {
136
146
  // Entry path of each node on the current ancestor chain; presence in this
137
147
  // map is what identifies a back-edge. Nodes are removed on the way out, so
@@ -161,6 +171,12 @@ function decycle(root: any) {
161
171
 
162
172
  const cyclePath = onPath.get(val)
163
173
  if (undefined !== cyclePath) {
174
+ let cuts = DECYCLED.get(node)
175
+ if (undefined === cuts) {
176
+ cuts = new Map()
177
+ DECYCLED.set(node, cuts)
178
+ }
179
+ cuts.set(String(key), val)
164
180
  node[key] = `[Circular *${cyclePath.join('.')}]`
165
181
  continue
166
182
  }
@@ -189,54 +205,63 @@ function refSiblings(node: any): any {
189
205
  }
190
206
 
191
207
 
192
- function addXRefsAndResolve(obj: any, root: any, visited?: WeakSet<any>) {
208
+ function addXRefsAndResolve(
209
+ obj: any,
210
+ root: any,
211
+ visited: WeakSet<any> = new WeakSet(),
212
+ expanding: Map<any, any> = new Map(),
213
+ ) {
193
214
  if (!obj || typeof obj !== 'object') return
194
- if (!visited) visited = new WeakSet()
195
215
  if (visited.has(obj)) return
196
216
  visited.add(obj)
197
217
 
198
- if (Array.isArray(obj)) {
199
- for (let i = 0; i < obj.length; i++) {
200
- const item = obj[i]
201
- if (item && typeof item === 'object') {
202
- if (typeof item.$ref === 'string') {
203
- const xref = item.$ref
204
- const resolved = resolvePointer(root, xref)
205
- if (resolved !== undefined) {
206
- obj[i] = { ...resolved, ...refSiblings(item), 'x-ref': xref }
207
- addXRefsAndResolve(obj[i], root, visited)
208
- } else {
209
- item['x-ref'] = xref
210
- addXRefsAndResolve(item, root, visited)
211
- }
212
- } else {
213
- addXRefsAndResolve(item, root, visited)
214
- }
215
- }
216
- }
217
- } else {
218
- for (const key of Object.keys(obj)) {
219
- const val = obj[key]
220
- if (val && typeof val === 'object') {
221
- if (typeof val.$ref === 'string') {
222
- const xref = val.$ref
223
- const resolved = resolvePointer(root, xref)
224
- if (resolved !== undefined) {
225
- obj[key] = { ...resolved, ...refSiblings(val), 'x-ref': xref }
226
- addXRefsAndResolve(obj[key], root, visited)
227
- } else {
228
- val['x-ref'] = xref
229
- addXRefsAndResolve(val, root, visited)
230
- }
231
- } else {
232
- addXRefsAndResolve(val, root, visited)
233
- }
218
+ const keys: (string | number)[] =
219
+ Array.isArray(obj) ? Array.from(obj.keys()) : Object.keys(obj)
220
+
221
+ for (const key of keys) {
222
+ const val = obj[key]
223
+ if (val && typeof val === 'object') {
224
+ if (typeof val.$ref === 'string') {
225
+ resolveRefSite(obj, key, root, visited, expanding)
226
+ } else {
227
+ addXRefsAndResolve(val, root, visited, expanding)
234
228
  }
235
229
  }
236
230
  }
237
231
  }
238
232
 
239
233
 
234
+ function resolveRefSite(
235
+ holder: any,
236
+ key: string | number,
237
+ root: any,
238
+ visited: WeakSet<any>,
239
+ expanding: Map<any, any>,
240
+ ) {
241
+ const site = holder[key]
242
+
243
+ const inProgress = expanding.get(site)
244
+ if (undefined !== inProgress) {
245
+ holder[key] = inProgress
246
+ return
247
+ }
248
+
249
+ const xref = site.$ref
250
+ const resolved = resolvePointer(root, xref)
251
+ if (resolved === undefined) {
252
+ site['x-ref'] = xref
253
+ addXRefsAndResolve(site, root, visited, expanding)
254
+ return
255
+ }
256
+
257
+ const copy = { ...resolved, ...refSiblings(site), 'x-ref': xref }
258
+ holder[key] = copy
259
+ expanding.set(site, copy)
260
+ addXRefsAndResolve(copy, root, visited, expanding)
261
+ expanding.delete(site)
262
+ }
263
+
264
+
240
265
  function resolvePointer(root: any, ref: string): any {
241
266
  const seen = new Set<string>()
242
267
  const siblings: any[] = []
@@ -316,6 +341,7 @@ function validateSource(kind: string, source: any, meta: { file: string }) {
316
341
 
317
342
  export {
318
343
  parse,
344
+ decycledChild,
319
345
  }
320
346
 
321
347
 
@@ -0,0 +1,244 @@
1
+ /* Copyright (c) 2026 Voxgig, MIT License */
2
+
3
+ import { decycledChild } from './parse'
4
+
5
+
6
+ const REFCOUNT_CAP = 1_000_000_000
7
+
8
+ const ROOT = '\x01ROOT'
9
+
10
+ const INDEX_RE = /^(0|[1-9]\d*)$/
11
+
12
+ type Body = Map<string, number>
13
+
14
+
15
+ function satAdd(a: number, b: number): number {
16
+ return Math.min(a + b, REFCOUNT_CAP)
17
+ }
18
+
19
+
20
+ function satMul(a: number, w: number): number {
21
+ if (0 === a || 0 === w) return 0
22
+ if (a > Math.floor(REFCOUNT_CAP / w)) return REFCOUNT_CAP
23
+ return a * w
24
+ }
25
+
26
+
27
+ function byCodePoint(a: string, b: string): number {
28
+ const n = Math.min(a.length, b.length)
29
+ for (let i = 0; i < n; i++) {
30
+ const x = a.charCodeAt(i)
31
+ const y = b.charCodeAt(i)
32
+ if (x !== y) return codePointRank(x) - codePointRank(y)
33
+ }
34
+ return a.length - b.length
35
+ }
36
+
37
+
38
+ // Surrogate units sort below U+E000-U+FFFF as UTF-16 but above them by code point.
39
+ function codePointRank(unit: number): number {
40
+ return unit < 0xD800 ? unit : unit < 0xE000 ? unit + 0x2000 : unit - 0x800
41
+ }
42
+
43
+
44
+ function isNode(v: any): boolean {
45
+ return null != v && 'object' === typeof v
46
+ }
47
+
48
+
49
+ function refLabel(v: any): string | undefined {
50
+ if (!isNode(v) || Array.isArray(v)) return undefined
51
+ if ('string' === typeof v['x-ref']) return v['x-ref']
52
+ if (!Object.prototype.hasOwnProperty.call(v, 'x-ref') && 'string' === typeof v.$ref) {
53
+ return v.$ref
54
+ }
55
+ return undefined
56
+ }
57
+
58
+
59
+ function hasKid(node: any, key: string): boolean {
60
+ if (!isNode(node)) return false
61
+ return Array.isArray(node) ?
62
+ INDEX_RE.test(key) && Number(key) < node.length :
63
+ Object.prototype.hasOwnProperty.call(node, key)
64
+ }
65
+
66
+
67
+ function hasKids(node: any): boolean {
68
+ if (!isNode(node)) return false
69
+ return 0 < (Array.isArray(node) ? node.length : Object.keys(node).length)
70
+ }
71
+
72
+
73
+ function kidKeys(node: any): string[] {
74
+ return Array.isArray(node) ?
75
+ node.map((_: any, i: number) => String(i)) : Object.keys(node)
76
+ }
77
+
78
+
79
+ // Parts join on '\0'. Escaping '\0' and '\x01' keeps keys injective and in part
80
+ // order, and an escape never puts 'R' after '\x01', so no key equals ROOT.
81
+ function nodeKey(label: string, over: string[]): string {
82
+ return [label, ...over]
83
+ .map(part => part.replace(/[\0\x01]/g, c => '\0' === c ? '\x01\x01' : '\x01\x02'))
84
+ .join('\0')
85
+ }
86
+
87
+
88
+ // Occurrences of each reference label per use in the resolved spec, where a $ref
89
+ // chain resolves to its first label.
90
+ function countRefs(def: any): Record<string, number> {
91
+ const targets = new Map<string, any>()
92
+ const target = (label: string): any => {
93
+ if (targets.has(label)) return targets.get(label)
94
+ let t: any = undefined
95
+ if (label.startsWith('#/')) {
96
+ t = def
97
+ for (const raw of label.substring(2).split('/')) {
98
+ const part = raw.replace(/~1/g, '/').replace(/~0/g, '~')
99
+ if (!hasKid(t, part)) {
100
+ t = undefined
101
+ break
102
+ }
103
+ t = decycledChild(t, part)
104
+ }
105
+ if (!isNode(t)) t = undefined
106
+ }
107
+ targets.set(label, t)
108
+ return t
109
+ }
110
+
111
+ const same = (a: any, b: any): boolean => {
112
+ if (isNode(a) && a === b) return true
113
+ const label = refLabel(a)
114
+ return undefined !== label && label === refLabel(b)
115
+ }
116
+
117
+ const labelOf = new Map<string, string>()
118
+ const skipOf = new Map<string, Set<string>>()
119
+ const found: string[] = []
120
+
121
+ const scan = (start: any, skip: Set<string>): Body => {
122
+ const body: Body = new Map()
123
+ const expanded = new Set<any>()
124
+ const stack: any[] = []
125
+ for (const k of kidKeys(start)) {
126
+ if ('x-ref' !== k && '$ref' !== k && !skip.has(k)) {
127
+ stack.push(decycledChild(start, k))
128
+ }
129
+ }
130
+
131
+ while (0 < stack.length) {
132
+ const v = stack.pop()
133
+ if (!isNode(v)) continue
134
+
135
+ const label = refLabel(v)
136
+ if (undefined === label) {
137
+ // Expanding a shared plain node once per scan is what ends a cycle of them.
138
+ if (expanded.has(v)) continue
139
+ expanded.add(v)
140
+ for (const k of kidKeys(v)) stack.push(decycledChild(v, k))
141
+ continue
142
+ }
143
+
144
+ const t = target(label)
145
+ const over: string[] = []
146
+ for (const k of Object.keys(v)) {
147
+ if ('x-ref' === k || '$ref' === k) continue
148
+ const vk = decycledChild(v, k)
149
+ if (hasKid(t, k)) {
150
+ const tk = decycledChild(t, k)
151
+ if (same(vk, tk)) continue
152
+ if (hasKids(tk)) over.push(k)
153
+ }
154
+ stack.push(vk)
155
+ }
156
+ over.sort(byCodePoint)
157
+
158
+ const node = nodeKey(label, over)
159
+ if (!labelOf.has(node)) {
160
+ labelOf.set(node, label)
161
+ skipOf.set(node, new Set(over))
162
+ found.push(node)
163
+ }
164
+ body.set(node, (body.get(node) ?? 0) + 1)
165
+ }
166
+
167
+ return body
168
+ }
169
+
170
+ const bodies = new Map<string, Body>([[ROOT, scan(def, new Set())]])
171
+ for (let i = 0; i < found.length; i++) {
172
+ const node = found[i]
173
+ const t = target(labelOf.get(node) as string)
174
+ bodies.set(node, undefined === t ? new Map() : scan(t, skipOf.get(node) as Set<string>))
175
+ }
176
+
177
+ const count = countPaths(bodies)
178
+
179
+ const out: Record<string, number> = {}
180
+ for (const node of found) {
181
+ const label = labelOf.get(node) as string
182
+ out[label] = satAdd(out[label] ?? 0, count.get(node) ?? 0)
183
+ }
184
+ return out
185
+ }
186
+
187
+
188
+ function countPaths(bodies: Map<string, Body>): Map<string, number> {
189
+ // Code-point order, so both ports cut the same edge of a reference cycle.
190
+ const successors = (u: string) =>
191
+ [...(bodies.get(u) as Body).keys()].sort(byCodePoint)
192
+
193
+ const onStack = new Set<string>([ROOT])
194
+ const finished = new Set<string>()
195
+ const backEdges = new Map<string, Set<string>>()
196
+ const post: string[] = []
197
+
198
+ const stack = [{ node: ROOT, next: successors(ROOT), i: 0 }]
199
+ while (0 < stack.length) {
200
+ const frame = stack[stack.length - 1]
201
+ if (frame.i < frame.next.length) {
202
+ const v = frame.next[frame.i++]
203
+ if (onStack.has(v)) {
204
+ let cut = backEdges.get(frame.node)
205
+ if (undefined === cut) {
206
+ cut = new Set()
207
+ backEdges.set(frame.node, cut)
208
+ }
209
+ cut.add(v)
210
+ }
211
+ else if (!finished.has(v)) {
212
+ onStack.add(v)
213
+ stack.push({ node: v, next: successors(v), i: 0 })
214
+ }
215
+ }
216
+ else {
217
+ stack.pop()
218
+ onStack.delete(frame.node)
219
+ finished.add(frame.node)
220
+ post.push(frame.node)
221
+ }
222
+ }
223
+
224
+ const count = new Map<string, number>([[ROOT, 1]])
225
+ for (let i = post.length - 1; 0 <= i; i--) {
226
+ const u = post[i]
227
+ const cu = count.get(u) ?? 0
228
+ const cut = backEdges.get(u)
229
+ for (const [v, w] of bodies.get(u) as Body) {
230
+ if (cut?.has(v)) continue
231
+ count.set(v, satAdd(count.get(v) ?? 0, satMul(cu, w)))
232
+ }
233
+ }
234
+ return count
235
+ }
236
+
237
+
238
+ export {
239
+ REFCOUNT_CAP,
240
+ byCodePoint,
241
+ countRefs,
242
+ satAdd,
243
+ satMul,
244
+ }
@@ -509,7 +509,7 @@ function compositeId(
509
509
  return { single: singleKeyOf(ment, identityParams(ment)) } as any
510
510
  }
511
511
 
512
- // `from` STATED IN guide.aon WINS PER PART, so a spec can correct one
512
+ // `from` STATED IN guide.aontu WINS PER PART, so a spec can correct one
513
513
  // mapping without restating the others — which matters because the
514
514
  // heuristic gets most of them right and the odd one wrong.
515
515
  const withFrom = (parts: string[], usesep: string) => {
@@ -27,7 +27,7 @@ import type {
27
27
 
28
28
  // The op names the transform resolves. Anything else under a guide path's
29
29
  // `op` map is dropped, and an unknown name (a verb such as `merge`, or a
30
- // typo) is dropped WITH A WARNING: guide.aon is the only correction surface
30
+ // typo) is dropped WITH A WARNING: guide.aontu is the only correction surface
31
31
  // (ADR-002), so a correction that vanishes silently defeats it. A non-CRUD
32
32
  // verb is declared as `action: <verb>: {}` beside a CRUD op on the same path.
33
33
  const RESOLVED_OPS = ['load', 'list', 'create', 'update', 'remove', 'patch']
@@ -40,7 +40,7 @@ const topTransform = async function(
40
40
  // Guarantee at least one sentence of API description. Many specs (e.g. the
41
41
  // readme.io-hosted Bluefin APIs) ship a placeholder `info.description` of
42
42
  // "." — letterless, useless prose. When the description is empty or has no
43
- // letters, synthesise a sentence from the title so the api-info.aon (and
43
+ // letters, synthesise a sentence from the title so the api-info.aontu (and
44
44
  // the docs generated from it) never carry an empty/degenerate description.
45
45
  kit.info.description = ensureDescription(kit.info)
46
46
 
package/src/types.ts CHANGED
@@ -268,10 +268,10 @@ type GuideEntity = {
268
268
  name: string
269
269
  orig: string
270
270
  // `false` drops the entity downstream (transform/entity.ts). Emitted by
271
- // the heuristic for an access-token exchange, and editable in guide.aon —
271
+ // the heuristic for an access-token exchange, and editable in guide.aontu —
272
272
  // which is the ONLY correction surface (ADR-002).
273
273
  active?: boolean
274
- // Why the heuristic deactivated it, so guide.aon reads as a record of a
274
+ // Why the heuristic deactivated it, so guide.aontu reads as a record of a
275
275
  // decision rather than an unexplained `active: false`.
276
276
  why_inactive?: string
277
277
  field?: Record<string, GuidePath>
package/src/utility.ts CHANGED
@@ -1141,7 +1141,7 @@ const CMP_RESULT_VERBS = [
1141
1141
 
1142
1142
  const CMP_PREFIXES = ['get_', 'post_', 'put_', 'delete_', 'patch_']
1143
1143
 
1144
- // A guide node (entity, path or op) is active unless guide.aon explicitly
1144
+ // A guide node (entity, path or op) is active unless guide.aontu explicitly
1145
1145
  // says otherwise. `active` has always been declared in the guide model and
1146
1146
  // documented as the escape hatch for a misclassified entity; this is the
1147
1147
  // single place that decides what it means, so entity/flow/operation transforms
@@ -1595,6 +1595,58 @@ function envelopeProp(resprops: any, opname: string): string | null {
1595
1595
  }
1596
1596
 
1597
1597
 
1598
+ // What a page may hold beside its records, compared without case, `_` or
1599
+ // `-`. Any other property is data, which makes the component a record.
1600
+ const ENVELOPE_PAGING_PROPS = new Set([
1601
+ 'count', 'total', 'totalcount', 'totalhits', 'totalitems', 'totalpages',
1602
+ 'totalresults', 'page', 'pages', 'pagecount', 'pagenumber', 'pagesize',
1603
+ 'perpage', 'limit', 'offset', 'cursor', 'next', 'nextcursor', 'nextpage',
1604
+ 'nextpagetoken', 'nexttoken', 'previous', 'prev', 'previouscursor',
1605
+ 'prevcursor', 'previouspage', 'prevpage', 'hasmore', 'hasnext',
1606
+ 'hasprevious', 'object', 'url',
1607
+ ])
1608
+
1609
+
1610
+ function isEnvelopePagingProp(name: string): boolean {
1611
+ return ENVELOPE_PAGING_PROPS.has(name.toLowerCase().replace(/[_-]/g, ''))
1612
+ }
1613
+
1614
+
1615
+ // The component a response envelope carries: the resolved reference of the
1616
+ // record envelopeProp unwraps to. Narrower than envelopeProp, since a record
1617
+ // with one structured property passes that test too: an envelope has no `id`,
1618
+ // a page holds nothing beside its records but paging, and a single-item
1619
+ // envelope holds nothing beside the item.
1620
+ function envelopeItemRef(schema: any, opname: string): string | null {
1621
+ const props = schema?.properties
1622
+ const key = envelopeProp(props, opname)
1623
+ if (null == key || null != props.id) {
1624
+ return null
1625
+ }
1626
+
1627
+ const prop = props[key]
1628
+ const islist = propIsList(prop)
1629
+ const rest = keysof(props).filter((k: string) => k !== key)
1630
+ if (islist ? !rest.every(isEnvelopePagingProp) : 0 < rest.length) {
1631
+ return null
1632
+ }
1633
+
1634
+ const item = islist ? prop.items : prop
1635
+ if (!isRecordSchema(item)) {
1636
+ return null
1637
+ }
1638
+
1639
+ const xref = item['x-ref']
1640
+ return 'string' === typeof xref && '' !== xref ? xref : null
1641
+ }
1642
+
1643
+
1644
+ function isRecordSchema(schema: any): boolean {
1645
+ return null != schema && 'object' === typeof schema && (
1646
+ null != schema.properties || null != schema.allOf || 'object' === schema.type)
1647
+ }
1648
+
1649
+
1598
1650
  function untaggedUnionBranches(schema: any): number {
1599
1651
  if (null == schema || 'object' !== typeof schema) {
1600
1652
  return 0
@@ -1757,6 +1809,7 @@ export {
1757
1809
  sortedEntries,
1758
1810
  isEntityWrapperProp,
1759
1811
  envelopeProp,
1812
+ envelopeItemRef,
1760
1813
  closedBodyTransform,
1761
1814
  untaggedUnionBranches,
1762
1815
  scanUntaggedUnion,
File without changes