@wildo-ai/saas-technical-doc 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/LICENSE +34 -0
  2. package/dist/esm/.builder.pid +9 -0
  3. package/dist/esm/build/csp-emit.d.ts +9 -0
  4. package/dist/esm/build/csp-emit.d.ts.map +1 -0
  5. package/dist/esm/build/csp-emit.js +8 -0
  6. package/dist/esm/build/csp-emit.js.map +1 -0
  7. package/dist/esm/build/load-materialized-frontend-providers.d.ts +9 -0
  8. package/dist/esm/build/load-materialized-frontend-providers.d.ts.map +1 -0
  9. package/dist/esm/build/load-materialized-frontend-providers.js +9 -0
  10. package/dist/esm/build/load-materialized-frontend-providers.js.map +1 -0
  11. package/dist/esm/companion/application-documentation/application-organization-role-documentation.d.ts +66 -0
  12. package/dist/esm/companion/application-documentation/application-organization-role-documentation.d.ts.map +1 -0
  13. package/dist/esm/companion/application-documentation/application-organization-role-documentation.js +195 -0
  14. package/dist/esm/companion/application-documentation/application-organization-role-documentation.js.map +1 -0
  15. package/dist/esm/companion/application-documentation/application-organization-unit-resource-documentation.d.ts +36 -0
  16. package/dist/esm/companion/application-documentation/application-organization-unit-resource-documentation.d.ts.map +1 -0
  17. package/dist/esm/companion/application-documentation/application-organization-unit-resource-documentation.js +71 -0
  18. package/dist/esm/companion/application-documentation/application-organization-unit-resource-documentation.js.map +1 -0
  19. package/dist/esm/companion/application-documentation/technical-documentation-engine-content-bundle.d.ts +37 -0
  20. package/dist/esm/companion/application-documentation/technical-documentation-engine-content-bundle.d.ts.map +1 -0
  21. package/dist/esm/companion/application-documentation/technical-documentation-engine-content-bundle.js +1865 -0
  22. package/dist/esm/companion/application-documentation/technical-documentation-engine-content-bundle.js.map +1 -0
  23. package/dist/esm/companion/application-documentation/technical-documentation-operational-evidence.d.ts +22 -0
  24. package/dist/esm/companion/application-documentation/technical-documentation-operational-evidence.d.ts.map +1 -0
  25. package/dist/esm/companion/application-documentation/technical-documentation-operational-evidence.js +31 -0
  26. package/dist/esm/companion/application-documentation/technical-documentation-operational-evidence.js.map +1 -0
  27. package/dist/esm/companion/application-documentation/technical-documentation-private-derivation.d.ts +31 -0
  28. package/dist/esm/companion/application-documentation/technical-documentation-private-derivation.d.ts.map +1 -0
  29. package/dist/esm/companion/application-documentation/technical-documentation-private-derivation.js +33 -0
  30. package/dist/esm/companion/application-documentation/technical-documentation-private-derivation.js.map +1 -0
  31. package/dist/esm/companion/application-documentation/technical-documentation-publication-policy.d.ts +13 -0
  32. package/dist/esm/companion/application-documentation/technical-documentation-publication-policy.d.ts.map +1 -0
  33. package/dist/esm/companion/application-documentation/technical-documentation-publication-policy.js +93 -0
  34. package/dist/esm/companion/application-documentation/technical-documentation-publication-policy.js.map +1 -0
  35. package/dist/esm/companion/content/application-consumer-documentation-content-loader.d.ts +52 -0
  36. package/dist/esm/companion/content/application-consumer-documentation-content-loader.d.ts.map +1 -0
  37. package/dist/esm/companion/content/application-consumer-documentation-content-loader.js +191 -0
  38. package/dist/esm/companion/content/application-consumer-documentation-content-loader.js.map +1 -0
  39. package/dist/esm/companion/index.d.ts +39 -0
  40. package/dist/esm/companion/index.d.ts.map +1 -0
  41. package/dist/esm/companion/index.js +39 -0
  42. package/dist/esm/companion/index.js.map +1 -0
  43. package/dist/esm/companion/openapi-generator.d.ts +94 -0
  44. package/dist/esm/companion/openapi-generator.d.ts.map +1 -0
  45. package/dist/esm/companion/openapi-generator.js +1562 -0
  46. package/dist/esm/companion/openapi-generator.js.map +1 -0
  47. package/dist/esm/companion/operation-projection.schemas.d.ts +797 -0
  48. package/dist/esm/companion/operation-projection.schemas.d.ts.map +1 -0
  49. package/dist/esm/companion/operation-projection.schemas.js +610 -0
  50. package/dist/esm/companion/operation-projection.schemas.js.map +1 -0
  51. package/dist/esm/companion/publish-result.types.d.ts +124 -0
  52. package/dist/esm/companion/publish-result.types.d.ts.map +1 -0
  53. package/dist/esm/companion/publish-result.types.js +28 -0
  54. package/dist/esm/companion/publish-result.types.js.map +1 -0
  55. package/dist/esm/companion/rendering/technical-documentation-authorized-bundle-reader.d.ts +26 -0
  56. package/dist/esm/companion/rendering/technical-documentation-authorized-bundle-reader.d.ts.map +1 -0
  57. package/dist/esm/companion/rendering/technical-documentation-authorized-bundle-reader.js +63 -0
  58. package/dist/esm/companion/rendering/technical-documentation-authorized-bundle-reader.js.map +1 -0
  59. package/dist/esm/companion/rendering/technical-documentation-build-measurement.d.ts +23 -0
  60. package/dist/esm/companion/rendering/technical-documentation-build-measurement.d.ts.map +1 -0
  61. package/dist/esm/companion/rendering/technical-documentation-build-measurement.js +104 -0
  62. package/dist/esm/companion/rendering/technical-documentation-build-measurement.js.map +1 -0
  63. package/dist/esm/companion/rendering/technical-documentation-docusaurus-renderer.d.ts +9 -0
  64. package/dist/esm/companion/rendering/technical-documentation-docusaurus-renderer.d.ts.map +1 -0
  65. package/dist/esm/companion/rendering/technical-documentation-docusaurus-renderer.js +421 -0
  66. package/dist/esm/companion/rendering/technical-documentation-docusaurus-renderer.js.map +1 -0
  67. package/dist/esm/companion/rendering/technical-documentation-managed-tree-validator.d.ts +72 -0
  68. package/dist/esm/companion/rendering/technical-documentation-managed-tree-validator.d.ts.map +1 -0
  69. package/dist/esm/companion/rendering/technical-documentation-managed-tree-validator.js +204 -0
  70. package/dist/esm/companion/rendering/technical-documentation-managed-tree-validator.js.map +1 -0
  71. package/dist/esm/companion/rendering/technical-documentation-markdown-renderer.d.ts +9 -0
  72. package/dist/esm/companion/rendering/technical-documentation-markdown-renderer.d.ts.map +1 -0
  73. package/dist/esm/companion/rendering/technical-documentation-markdown-renderer.js +106 -0
  74. package/dist/esm/companion/rendering/technical-documentation-markdown-renderer.js.map +1 -0
  75. package/dist/esm/companion/rendering/technical-documentation-mdx-escaping.d.ts +43 -0
  76. package/dist/esm/companion/rendering/technical-documentation-mdx-escaping.d.ts.map +1 -0
  77. package/dist/esm/companion/rendering/technical-documentation-mdx-escaping.js +88 -0
  78. package/dist/esm/companion/rendering/technical-documentation-mdx-escaping.js.map +1 -0
  79. package/dist/esm/companion/rendering/technical-documentation-openapi-renderer.d.ts +7 -0
  80. package/dist/esm/companion/rendering/technical-documentation-openapi-renderer.d.ts.map +1 -0
  81. package/dist/esm/companion/rendering/technical-documentation-openapi-renderer.js +51 -0
  82. package/dist/esm/companion/rendering/technical-documentation-openapi-renderer.js.map +1 -0
  83. package/dist/esm/companion/rendering/technical-documentation-render-model.d.ts +45 -0
  84. package/dist/esm/companion/rendering/technical-documentation-render-model.d.ts.map +1 -0
  85. package/dist/esm/companion/rendering/technical-documentation-render-model.js +178 -0
  86. package/dist/esm/companion/rendering/technical-documentation-render-model.js.map +1 -0
  87. package/dist/esm/companion/rendering/technical-documentation-search-index-renderer.d.ts +3 -0
  88. package/dist/esm/companion/rendering/technical-documentation-search-index-renderer.d.ts.map +1 -0
  89. package/dist/esm/companion/rendering/technical-documentation-search-index-renderer.js +49 -0
  90. package/dist/esm/companion/rendering/technical-documentation-search-index-renderer.js.map +1 -0
  91. package/dist/esm/companion/spec-to-operation-doc.d.ts +176 -0
  92. package/dist/esm/companion/spec-to-operation-doc.d.ts.map +1 -0
  93. package/dist/esm/companion/spec-to-operation-doc.js +326 -0
  94. package/dist/esm/companion/spec-to-operation-doc.js.map +1 -0
  95. package/dist/esm/companion/technical-documentation-asset-path.d.ts +16 -0
  96. package/dist/esm/companion/technical-documentation-asset-path.d.ts.map +1 -0
  97. package/dist/esm/companion/technical-documentation-asset-path.js +19 -0
  98. package/dist/esm/companion/technical-documentation-asset-path.js.map +1 -0
  99. package/dist/esm/companion/technical-documentation-capture-execution-port.d.ts +14 -0
  100. package/dist/esm/companion/technical-documentation-capture-execution-port.d.ts.map +1 -0
  101. package/dist/esm/companion/technical-documentation-capture-execution-port.js +1 -0
  102. package/dist/esm/companion/technical-documentation-capture-execution-port.js.map +1 -0
  103. package/dist/esm/companion/technical-documentation-diagram-materializer.d.ts +25 -0
  104. package/dist/esm/companion/technical-documentation-diagram-materializer.d.ts.map +1 -0
  105. package/dist/esm/companion/technical-documentation-diagram-materializer.js +86 -0
  106. package/dist/esm/companion/technical-documentation-diagram-materializer.js.map +1 -0
  107. package/dist/esm/companion/technical-documentation-placeholder-materializer.d.ts +17 -0
  108. package/dist/esm/companion/technical-documentation-placeholder-materializer.d.ts.map +1 -0
  109. package/dist/esm/companion/technical-documentation-placeholder-materializer.js +63 -0
  110. package/dist/esm/companion/technical-documentation-placeholder-materializer.js.map +1 -0
  111. package/dist/esm/companion/zod-to-openapi.d.ts +67 -0
  112. package/dist/esm/companion/zod-to-openapi.d.ts.map +1 -0
  113. package/dist/esm/companion/zod-to-openapi.js +211 -0
  114. package/dist/esm/companion/zod-to-openapi.js.map +1 -0
  115. package/dist/esm/companion-exports.d.ts +32 -0
  116. package/dist/esm/companion-exports.d.ts.map +1 -0
  117. package/dist/esm/companion-exports.js +32 -0
  118. package/dist/esm/companion-exports.js.map +1 -0
  119. package/dist/esm/config/define-tech-doc-config.d.ts +38 -0
  120. package/dist/esm/config/define-tech-doc-config.d.ts.map +1 -0
  121. package/dist/esm/config/define-tech-doc-config.js +40 -0
  122. package/dist/esm/config/define-tech-doc-config.js.map +1 -0
  123. package/dist/esm/config/index.d.ts +22 -0
  124. package/dist/esm/config/index.d.ts.map +1 -0
  125. package/dist/esm/config/index.js +22 -0
  126. package/dist/esm/config/index.js.map +1 -0
  127. package/dist/esm/config/wildo-tech-doc-config.schemas.d.ts +204 -0
  128. package/dist/esm/config/wildo-tech-doc-config.schemas.d.ts.map +1 -0
  129. package/dist/esm/config/wildo-tech-doc-config.schemas.js +192 -0
  130. package/dist/esm/config/wildo-tech-doc-config.schemas.js.map +1 -0
  131. package/dist/esm/content/application-consumer-documentation-content-manifest.schemas.d.ts +82 -0
  132. package/dist/esm/content/application-consumer-documentation-content-manifest.schemas.d.ts.map +1 -0
  133. package/dist/esm/content/application-consumer-documentation-content-manifest.schemas.js +114 -0
  134. package/dist/esm/content/application-consumer-documentation-content-manifest.schemas.js.map +1 -0
  135. package/dist/esm/content/application-consumer-documentation-content.techdoc.d.ts +420 -0
  136. package/dist/esm/content/application-consumer-documentation-content.techdoc.d.ts.map +1 -0
  137. package/dist/esm/content/application-consumer-documentation-content.techdoc.js +9619 -0
  138. package/dist/esm/content/application-consumer-documentation-content.techdoc.js.map +1 -0
  139. package/dist/esm/content.exports.d.ts +9 -0
  140. package/dist/esm/content.exports.d.ts.map +1 -0
  141. package/dist/esm/content.exports.js +9 -0
  142. package/dist/esm/content.exports.js.map +1 -0
  143. package/dist/esm/index.d.ts +26 -0
  144. package/dist/esm/index.d.ts.map +1 -0
  145. package/dist/esm/index.js +26 -0
  146. package/dist/esm/index.js.map +1 -0
  147. package/dist/esm/openapi/api-reference-link-index.d.ts +98 -0
  148. package/dist/esm/openapi/api-reference-link-index.d.ts.map +1 -0
  149. package/dist/esm/openapi/api-reference-link-index.js +301 -0
  150. package/dist/esm/openapi/api-reference-link-index.js.map +1 -0
  151. package/dist/esm/openapi/api-reference-targets.d.ts +71 -0
  152. package/dist/esm/openapi/api-reference-targets.d.ts.map +1 -0
  153. package/dist/esm/openapi/api-reference-targets.js +114 -0
  154. package/dist/esm/openapi/api-reference-targets.js.map +1 -0
  155. package/dist/esm/openapi/index.d.ts +20 -0
  156. package/dist/esm/openapi/index.d.ts.map +1 -0
  157. package/dist/esm/openapi/index.js +20 -0
  158. package/dist/esm/openapi/index.js.map +1 -0
  159. package/dist/esm/openapi/openapi-generation-output.schemas.d.ts +80 -0
  160. package/dist/esm/openapi/openapi-generation-output.schemas.d.ts.map +1 -0
  161. package/dist/esm/openapi/openapi-generation-output.schemas.js +76 -0
  162. package/dist/esm/openapi/openapi-generation-output.schemas.js.map +1 -0
  163. package/dist/esm/openapi-reference-model.exports.d.ts +10 -0
  164. package/dist/esm/openapi-reference-model.exports.d.ts.map +1 -0
  165. package/dist/esm/openapi-reference-model.exports.js +10 -0
  166. package/dist/esm/openapi-reference-model.exports.js.map +1 -0
  167. package/dist/esm/runtime/AuthExchangePage.d.ts +84 -0
  168. package/dist/esm/runtime/AuthExchangePage.d.ts.map +1 -0
  169. package/dist/esm/runtime/AuthExchangePage.js +188 -0
  170. package/dist/esm/runtime/AuthExchangePage.js.map +1 -0
  171. package/dist/esm/runtime/DocsAuthContext.d.ts +119 -0
  172. package/dist/esm/runtime/DocsAuthContext.d.ts.map +1 -0
  173. package/dist/esm/runtime/DocsAuthContext.js +171 -0
  174. package/dist/esm/runtime/DocsAuthContext.js.map +1 -0
  175. package/dist/esm/runtime/decode-jwt-claims.d.ts +39 -0
  176. package/dist/esm/runtime/decode-jwt-claims.d.ts.map +1 -0
  177. package/dist/esm/runtime/decode-jwt-claims.js +86 -0
  178. package/dist/esm/runtime/decode-jwt-claims.js.map +1 -0
  179. package/dist/esm/runtime/docs-auth-client.d.ts +193 -0
  180. package/dist/esm/runtime/docs-auth-client.d.ts.map +1 -0
  181. package/dist/esm/runtime/docs-auth-client.js +211 -0
  182. package/dist/esm/runtime/docs-auth-client.js.map +1 -0
  183. package/dist/esm/runtime/docs-auth-session.schemas.d.ts +77 -0
  184. package/dist/esm/runtime/docs-auth-session.schemas.d.ts.map +1 -0
  185. package/dist/esm/runtime/docs-auth-session.schemas.js +50 -0
  186. package/dist/esm/runtime/docs-auth-session.schemas.js.map +1 -0
  187. package/dist/esm/runtime/frontend-provider-registry.techdoc.d.ts +17 -0
  188. package/dist/esm/runtime/frontend-provider-registry.techdoc.d.ts.map +1 -0
  189. package/dist/esm/runtime/frontend-provider-registry.techdoc.js +23 -0
  190. package/dist/esm/runtime/frontend-provider-registry.techdoc.js.map +1 -0
  191. package/dist/esm/runtime/index.d.ts +57 -0
  192. package/dist/esm/runtime/index.d.ts.map +1 -0
  193. package/dist/esm/runtime/index.js +76 -0
  194. package/dist/esm/runtime/index.js.map +1 -0
  195. package/dist/esm/runtime/openapi-reference-conservation.d.ts +20 -0
  196. package/dist/esm/runtime/openapi-reference-conservation.d.ts.map +1 -0
  197. package/dist/esm/runtime/openapi-reference-conservation.js +102 -0
  198. package/dist/esm/runtime/openapi-reference-conservation.js.map +1 -0
  199. package/dist/esm/runtime/openapi-reference-model.d.ts +224 -0
  200. package/dist/esm/runtime/openapi-reference-model.d.ts.map +1 -0
  201. package/dist/esm/runtime/openapi-reference-model.js +579 -0
  202. package/dist/esm/runtime/openapi-reference-model.js.map +1 -0
  203. package/dist/esm/runtime/openapi-reference-view.d.ts +13 -0
  204. package/dist/esm/runtime/openapi-reference-view.d.ts.map +1 -0
  205. package/dist/esm/runtime/openapi-reference-view.js +284 -0
  206. package/dist/esm/runtime/openapi-reference-view.js.map +1 -0
  207. package/dist/esm/runtime/use-docs-auth-session.d.ts +26 -0
  208. package/dist/esm/runtime/use-docs-auth-session.d.ts.map +1 -0
  209. package/dist/esm/runtime/use-docs-auth-session.js +34 -0
  210. package/dist/esm/runtime/use-docs-auth-session.js.map +1 -0
  211. package/dist/esm/runtime/use-docs-frontend-provider-registry.d.ts +3 -0
  212. package/dist/esm/runtime/use-docs-frontend-provider-registry.d.ts.map +1 -0
  213. package/dist/esm/runtime/use-docs-frontend-provider-registry.js +5 -0
  214. package/dist/esm/runtime/use-docs-frontend-provider-registry.js.map +1 -0
  215. package/dist/tsconfig.build.tsbuildinfo +1 -0
  216. package/package.json +117 -0
@@ -0,0 +1,1562 @@
1
+ /**
2
+ * @wildo-package @wildo-ai/saas-technical-doc/companion (generator)
3
+ *
4
+ * Pure-function OpenAPI 3.1 generator (saas-technical-doc.md Step 3).
5
+ *
6
+ * Pipeline:
7
+ *
8
+ * `OpenApiGenerationInput`
9
+ * ──filterApiCallOperations──► (assert URL-bearing variants)
10
+ * ──groupOperationsByConsumerApiSection──► (use source-resolved section)
11
+ * ──buildOpenApiDocument──► (per-section OpenAPI 3.1 doc as a JS object)
12
+ * ──assembleOpenApiGenerationOutput──►(top-level `OpenApiGenerationOutput`)
13
+ *
14
+ * Key design points:
15
+ *
16
+ * K-1: This module is `OpenApiGenerationInput` → `OpenApiGenerationOutput`
17
+ * and contains no I/O, no global state, and no DI. It is consumed by
18
+ * the companion controller (which owns the introspection hop +
19
+ * filesystem write — see Step 4) and by unit tests in isolation.
20
+ *
21
+ * K-2: Sections that contain ZERO operations after filtering are
22
+ * OMITTED from the output's `sections[]` array (per the
23
+ * `OpenApiGenerationOutputSchema` JSDoc). The companion UI
24
+ * surfaces this as an absent section — the empty section is
25
+ * intentional, not a generator bug.
26
+ *
27
+ * K-3: The generator uses Zod-parsed input (validated up-front via
28
+ * `OpenApiGenerationInputSchema`) so every downstream helper can
29
+ * assume well-formed projections. Callers MUST call
30
+ * `generateOpenApiDocuments(input)` (which parses) and NOT the
31
+ * sub-helpers directly with unparsed data.
32
+ *
33
+ * K-4: The semantic output carries one document object. YAML/JSON
34
+ * serialization is renderer-owned so divergent format payloads cannot
35
+ * cross this contract.
36
+ *
37
+ * @wildo-boundary
38
+ * Imports only the portable OpenAPI output contract, the local operation
39
+ * projection contract, the lightweight public runtime constants and
40
+ * specification-prose cleanup. No serializer, React or saas-models root
41
+ * barrel enters this semantic generator.
42
+ */
43
+ import { WildoHeaderKeys } from '@wildo-ai/saas-models/public-runtime';
44
+ import { OpenApiSection, } from '../openapi/openapi-generation-output.schemas.js';
45
+ import { OpenApiGenerationInputSchema, OPENAPI_RESOURCE_IDENTITY_CONTRACT_VERSION, OperationProjectionAuthenticationMode, OperationProjectionVariantType, } from './operation-projection.schemas.js';
46
+ import { stripImplementationNoise } from './spec-to-operation-doc.js';
47
+ /**
48
+ * Retains every projected URL-bearing operation.
49
+ *
50
+ * - Variant outside `API_CALL` / `API_CALL_WITH_CALLBACK` (K-5):
51
+ * defense-in-depth re-check on top of the projector's filter.
52
+ * If a non-API variant ever leaks through, drop it silently here
53
+ * rather than throwing — the projector is the single source of
54
+ * truth for "should this op even be in the projection at all," but
55
+ * we don't want a future projector regression to corrupt the
56
+ * generated doc with non-HTTP operations.
57
+ *
58
+ * `isApiKeyAccessDisabled` controls the operation's accepted authentication
59
+ * schemes. It does not make an HTTP operation disappear from its API contract.
60
+ *
61
+ * Returns a NEW array — does not mutate the input.
62
+ */
63
+ export function filterApiCallOperations(operations) {
64
+ return operations.filter((op) => {
65
+ if (op.variantType !== OperationProjectionVariantType.API_CALL &&
66
+ op.variantType !== OperationProjectionVariantType.API_CALL_WITH_CALLBACK) {
67
+ return false;
68
+ }
69
+ return true;
70
+ });
71
+ }
72
+ /**
73
+ * Groups operations by the explicit consumer-facing section resolved by the
74
+ * source projector. This layer deliberately does not reinterpret role strings
75
+ * or internal resource scopes: doing so would create a second authority for
76
+ * which product reference owns an operation.
77
+ *
78
+ * Returns a frozen `Record` keyed by `OpenApiSection`. Sections with
79
+ * zero operations are returned as empty arrays (NOT omitted) so the
80
+ * caller's filtering logic in `assembleOpenApiGenerationOutput` is the
81
+ * single place that decides "drop empty sections from the output."
82
+ */
83
+ export function groupOperationsByConsumerApiSection(operations) {
84
+ const apiReferenceOperations = [];
85
+ const applicationAdministrationOperations = [];
86
+ for (const op of operations) {
87
+ switch (op.consumerApiSection) {
88
+ case OpenApiSection.API_REFERENCE:
89
+ apiReferenceOperations.push(op);
90
+ break;
91
+ case OpenApiSection.APPLICATION_ADMINISTRATION_API_REFERENCE:
92
+ applicationAdministrationOperations.push(op);
93
+ break;
94
+ default: {
95
+ const exhaustiveSection = op.consumerApiSection;
96
+ throw new Error(`OpenAPI generator: unsupported consumer API section ${String(exhaustiveSection)}`);
97
+ }
98
+ }
99
+ }
100
+ return Object.freeze({
101
+ [OpenApiSection.API_REFERENCE]: Object.freeze(apiReferenceOperations),
102
+ [OpenApiSection.APPLICATION_ADMINISTRATION_API_REFERENCE]: Object.freeze(applicationAdministrationOperations),
103
+ });
104
+ }
105
+ /**
106
+ * Stable, deterministic ordering for operations within an OpenAPI
107
+ * doc. Sorted by `(resourceIdentifier, path, httpVerb)` so the
108
+ * generated YAML diffs cleanly across runs even if the projector
109
+ * yields operations in a different order (e.g. iteration order
110
+ * depending on the resource registry walk).
111
+ */
112
+ function sortOperationsForDeterministicOutput(operations) {
113
+ return [...operations].sort((a, b) => {
114
+ if (a.resourceIdentifier !== b.resourceIdentifier) {
115
+ return a.resourceIdentifier.localeCompare(b.resourceIdentifier);
116
+ }
117
+ if (a.path !== b.path) {
118
+ return a.path.localeCompare(b.path);
119
+ }
120
+ return a.httpVerb.localeCompare(b.httpVerb);
121
+ });
122
+ }
123
+ /**
124
+ * Maps an `OperationProjection` to its developer-facing OpenAPI `operationId`.
125
+ *
126
+ * VERB-FIRST camelCase — the convention the best public API references and the
127
+ * OpenAPI/SDK-generator ecosystem converge on (`createUser`, `listOrganizations`,
128
+ * `rotateApiKey`; see Speakeasy + Redocly operationId guidance). The
129
+ * `operationId` becomes the generated client method name; combined with the
130
+ * resource TAG, tag-grouping generators yield `sdk.organizations.create()` and
131
+ * flat generators yield `createOrganization()`.
132
+ *
133
+ * Composition: `<verb><ResourceNoun><Qualifiers>`, lower-camelCased.
134
+ *
135
+ * - **verb** — the standard CRUD identifiers (the LOWERCASE
136
+ * `CoreResourceOperation` values the projector actually emits) map to
137
+ * idiomatic verbs (`create→create`, `read→get`, `list→list`,
138
+ * `update→update`, `delete→delete`, `search→search` — see {@link CRUD_VERBS});
139
+ * any other identifier becomes its own verb phrase via `pascal`, whether a
140
+ * custom op (`exportAuditLogs`→`exportAuditLogs`, `EXPORT_AUDIT_LOGS`→
141
+ * `exportAuditLogs`) or a framework collection op the map omits
142
+ * (`count`→`count`, `create_many`→`createMany`).
143
+ * - **ResourceNoun** — `resourceIdentifier` PascalCased with camelCase word
144
+ * boundaries PRESERVED (`organizationApiKeys`→`OrganizationApiKeys`); the
145
+ * prior implementation lowercased segment interiors and produced
146
+ * `Organizationapikeys`.
147
+ * - **Redundancy** — a custom verb phrase that already contains the resource
148
+ * noun drops the repeat (`exportAuditLogs`, not `exportAuditLogsAuditLogs`).
149
+ * - **Qualifiers** — the `operationKey` segments AFTER the base identifier:
150
+ * the semantic `Via<Relationship>` disambiguator for relationship-nested
151
+ * paths, the `summary`/`context` data-mode, and `BULK`. Each is PascalCased
152
+ * and appended in order.
153
+ *
154
+ * Unique within the document and stable across runs (the multi-path
155
+ * disambiguator is the relationship name, not a positional index).
156
+ */
157
+ /**
158
+ * Split a string on separators AND camelCase boundaries (lowercase/digit →
159
+ * uppercase) so multi-word camelCase identifiers keep their word boundaries.
160
+ */
161
+ function splitWords(input) {
162
+ return input.split(/[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])/u).filter((segment) => segment.length > 0);
163
+ }
164
+ /**
165
+ * Industry-standard initialisms whose casing carries meaning in public API
166
+ * vocabulary. This is presentation-only: stable resource and operation
167
+ * identities remain unchanged in tags, operation ids and `x-wildo` metadata.
168
+ */
169
+ const API_REFERENCE_DISPLAY_INITIALISM_BY_TOKEN = Object.freeze({
170
+ a2a: 'A2A',
171
+ api: 'API',
172
+ dlq: 'DLQ',
173
+ idp: 'IdP',
174
+ ip: 'IP',
175
+ jwt: 'JWT',
176
+ m2m: 'M2M',
177
+ mcp: 'MCP',
178
+ oauth: 'OAuth',
179
+ oidc: 'OIDC',
180
+ pdf: 'PDF',
181
+ saml: 'SAML',
182
+ scim: 'SCIM',
183
+ siem: 'SIEM',
184
+ sso: 'SSO',
185
+ url: 'URL',
186
+ });
187
+ function apiReferenceDisplayWord(word, capitalizeOrdinaryWord) {
188
+ const initialism = API_REFERENCE_DISPLAY_INITIALISM_BY_TOKEN[word.toLowerCase()];
189
+ if (initialism !== undefined)
190
+ return initialism;
191
+ const lowercaseWord = word.toLowerCase();
192
+ return capitalizeOrdinaryWord
193
+ ? `${lowercaseWord.charAt(0).toUpperCase()}${lowercaseWord.slice(1)}`
194
+ : lowercaseWord;
195
+ }
196
+ /**
197
+ * PascalCase a string. Lowercases the interior ONLY for all-uppercase words
198
+ * (SCREAMING_SNAKE op identifiers like `EXPORT`, `EXCHANGE` → `Export`,
199
+ * `Exchange`); camelCase words (`Api`, `organization`) keep their interior so
200
+ * word boundaries survive (`organizationApiKeys` → `OrganizationApiKeys`).
201
+ * Shared by `buildOperationId` and the `components/schemas` namer
202
+ * (`componentSchemaName`) so operation ids and model names stay consistent.
203
+ */
204
+ function pascal(input) {
205
+ return splitWords(input)
206
+ .map((word) => {
207
+ const rest = /^[A-Z0-9]+$/u.test(word) ? word.slice(1).toLowerCase() : word.slice(1);
208
+ return word.charAt(0).toUpperCase() + rest;
209
+ })
210
+ .join('');
211
+ }
212
+ /**
213
+ * Idiomatic verb-first `operationId` verbs for the standard CRUD identifiers.
214
+ *
215
+ * Keyed on the LOWERCASE `@wildo-ai/saas-models` `CoreResourceOperation` VALUES
216
+ * (`'read'`, `'create'`, `'list'`, …) — NOT the SCREAMING_SNAKE enum MEMBER names.
217
+ * The projector sets `baseOperationIdentifier` to
218
+ * `String(operation.operationIdentifier)`, which for a core operation IS the
219
+ * lowercase enum value, so the lookup key MUST be lowercase.
220
+ *
221
+ * (History: this map was originally keyed on `'READ'` / `'CREATE'` / … — the
222
+ * enum member names — so no lookup ever matched at runtime and every base fell
223
+ * through to the custom-verb `pascal` path. It went unnoticed because
224
+ * `pascal(value)` lower-cased equals the intended verb for `create` / `list` /
225
+ * `update` / `delete` / `search` — the whole map was a no-op EXCEPT `read`,
226
+ * which produced `readX` instead of the intended `getX`.)
227
+ *
228
+ * Mirrored as string literals for the same boundary reason as the other
229
+ * framework constants in this file — the generator must NOT import
230
+ * `@wildo-ai/saas-models` (see the module `@wildo-boundary` note). Keep in sync
231
+ * with `CoreResourceOperation`.
232
+ *
233
+ * `read → get` is the sole non-identity mapping; the other five are listed for
234
+ * self-documentation and drift-resistance (they equal `pascal(value)`). Every
235
+ * OTHER identifier — custom ops AND the framework's own `count` / `create_many` /
236
+ * `update_many` / `delete_many` — becomes its own verb phrase via `pascal`
237
+ * (`create_many → createMany…`, `export_audit_logs → exportAuditLogs…`).
238
+ */
239
+ const CRUD_VERBS = {
240
+ create: 'create',
241
+ read: 'get',
242
+ list: 'list',
243
+ update: 'update',
244
+ delete: 'delete',
245
+ search: 'search',
246
+ };
247
+ /**
248
+ * Human, sentence-case verb labels for the default operation TITLE (`summary`).
249
+ *
250
+ * Keyed on the same lowercase `CoreResourceOperation` values as {@link CRUD_VERBS}
251
+ * (see that constant for the boundary + casing rationale). NB the TITLE verb for
252
+ * a read is the plain `'Read'` (e.g. "Read application auth policy"), distinct
253
+ * from the `operationId` verb `get` — the two surfaces intentionally diverge.
254
+ * Identifiers absent here fall through to a humanised phrase of the identifier
255
+ * itself in `buildDefaultSummary`.
256
+ */
257
+ const VERB_LABEL = {
258
+ create: 'Create',
259
+ read: 'Read',
260
+ list: 'List',
261
+ update: 'Update',
262
+ delete: 'Delete',
263
+ search: 'Search',
264
+ };
265
+ function buildOperationId(op) {
266
+ const base = op.baseOperationIdentifier;
267
+ const isCustom = CRUD_VERBS[base] === undefined;
268
+ const verbPascal = isCustom ? pascal(base) : pascal(CRUD_VERBS[base]);
269
+ const resourcePascal = pascal(op.resourceIdentifier);
270
+ // A custom verb phrase that already names the resource (e.g. EXPORT_AUDIT_LOGS
271
+ // on `auditLogs`) drops the repeated noun.
272
+ const corePascal = isCustom && verbPascal.includes(resourcePascal)
273
+ ? verbPascal
274
+ : `${verbPascal}${resourcePascal}`;
275
+ // Qualifiers = `operationKey` with the leading base identifier removed (the
276
+ // base can itself contain `_`, so we strip by length, not by splitting).
277
+ const qualifierTail = op.operationKey.startsWith(base)
278
+ ? op.operationKey.slice(base.length)
279
+ : '';
280
+ const qualifiersPascal = qualifierTail.split('_').filter(Boolean).map(pascal).join('');
281
+ const combined = `${corePascal}${qualifiersPascal}`;
282
+ return combined.charAt(0).toLowerCase() + combined.slice(1);
283
+ }
284
+ /**
285
+ * Default summary fallback. Used when the projector did not provide
286
+ * an explicit `summary` so the docs site never renders a blank title.
287
+ */
288
+ /**
289
+ * The operation TITLE (OpenAPI `summary` — page title, nav label, breadcrumb).
290
+ *
291
+ * A SHORT, human, derived action label — NOT the authored `purpose` prose
292
+ * (that is impl-aware intent text and now lives in the `description`; see
293
+ * `buildOperationDocFromSpec`). Mirrors the verb-first `operationId` but rendered
294
+ * as a readable sentence-case phrase:
295
+ *
296
+ * read + applicationAuthPolicy → "Read application auth policy"
297
+ * list + organizationApiKeys (summary variant) → "List organization api keys (summary)"
298
+ * create + appAnnouncements via posted-by-users → "Create app announcements via posted by users"
299
+ * export_audit_logs + auditLogs → "Export audit logs"
300
+ *
301
+ * The qualifier suffixes (`Via<…>`, `summary`/`context` data-mode, `BULK`) are
302
+ * humanised so the multiple endpoints of one resource read distinctly in the
303
+ * sidebar instead of colliding on the same title.
304
+ */
305
+ function buildDefaultSummary(op) {
306
+ const words = (input) => input.split(/[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])/u).filter((segment) => segment.length > 0);
307
+ const sentence = (input) => words(input).map((word) => apiReferenceDisplayWord(word, false)).join(' ');
308
+ const base = op.baseOperationIdentifier;
309
+ const verbPhrase = VERB_LABEL[base] ?? sentence(base);
310
+ const resourcePhrase = sentence(op.resourceIdentifier);
311
+ const actionWords = words(verbPhrase).map((word) => word.toLowerCase());
312
+ const resourceWords = words(resourcePhrase).map((word) => word.toLowerCase());
313
+ const actionAlreadyNamesResource = resourceWords.length > 0
314
+ && actionWords.length >= resourceWords.length
315
+ && resourceWords.every((word, index) => actionWords.at(index - resourceWords.length) === word);
316
+ let title = actionAlreadyNamesResource
317
+ ? `${verbPhrase.charAt(0).toUpperCase()}${verbPhrase.slice(1)}`
318
+ : `${verbPhrase.charAt(0).toUpperCase()}${verbPhrase.slice(1)} ${resourcePhrase}`.trim();
319
+ const qualifierTail = op.operationKey.startsWith(base) ? op.operationKey.slice(base.length) : '';
320
+ for (const qualifier of qualifierTail.split('_').filter(Boolean)) {
321
+ if (qualifier.startsWith('Via')) {
322
+ title += ` via ${sentence(qualifier.slice(3))}`;
323
+ }
324
+ else if (qualifier === 'BULK') {
325
+ title += ' (bulk)';
326
+ }
327
+ else if (qualifier.toLowerCase() === 'summary' || qualifier.toLowerCase() === 'context') {
328
+ title += ` (${qualifier.toLowerCase()})`;
329
+ }
330
+ else {
331
+ title += ` ${sentence(qualifier)}`;
332
+ }
333
+ }
334
+ return title;
335
+ }
336
+ /**
337
+ * Extracts `{paramName}` placeholders from an OpenAPI-style template
338
+ * path. Used to derive `parameters[]: in: path` entries automatically
339
+ * — the projector does not need to redeclare them.
340
+ *
341
+ * Returns an empty array for paths with no placeholders.
342
+ */
343
+ function extractPathParameters(path) {
344
+ const matches = path.matchAll(/\{([^}]+)\}/gu);
345
+ return Array.from(matches, (match) => match[1]);
346
+ }
347
+ function isPlainObject(value) {
348
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
349
+ }
350
+ function isQueryParameterVerb(httpVerb) {
351
+ return (httpVerb === 'get'
352
+ || httpVerb === 'delete');
353
+ }
354
+ /**
355
+ * Converts a top-level object JSON Schema fragment into OpenAPI query
356
+ * parameters for query-style verbs (GET / DELETE). Request examples are carried
357
+ * onto the individual OpenAPI Parameter Objects: these methods have no JSON
358
+ * body, so dropping an authored example here would make a valid source example
359
+ * invisible to both an OpenAPI viewer and the Wildo renderer.
360
+ *
361
+ * Why fail-loud on non-object schemas:
362
+ *
363
+ * The projection explicitly allows `requestBodySchema` to represent
364
+ * query input for GET / DELETE operations. If a projector bug emits a
365
+ * scalar / array / malformed schema here, silently reusing it as an
366
+ * OpenAPI `requestBody` would recreate the exact mis-documentation bug
367
+ * this helper exists to close. Throwing keeps the spec honest and pins
368
+ * the regression to the generator boundary.
369
+ */
370
+ function buildQueryParametersFromRequestSchema(op) {
371
+ const schema = op.requestBodySchema;
372
+ if (schema === null) {
373
+ return [];
374
+ }
375
+ if (!isPlainObject(schema) || schema.type !== 'object') {
376
+ throw new Error(`OpenAPI generator: ${op.httpVerb.toUpperCase()} ${op.path} for `
377
+ + `${op.resourceIdentifier}.${op.operationKey} carries a non-object request schema. `
378
+ + `GET/DELETE request schemas must be object-shaped so the generator can emit query parameters.`);
379
+ }
380
+ const properties = schema.properties;
381
+ if (properties === undefined) {
382
+ return [];
383
+ }
384
+ if (!isPlainObject(properties)) {
385
+ throw new Error(`OpenAPI generator: ${op.httpVerb.toUpperCase()} ${op.path} for `
386
+ + `${op.resourceIdentifier}.${op.operationKey} has an object request schema with malformed `
387
+ + '`properties`. Expected a plain object so query parameters can be emitted deterministically.');
388
+ }
389
+ const requiredFields = Array.isArray(schema.required)
390
+ ? new Set(schema.required.filter((value) => typeof value === 'string'))
391
+ : new Set();
392
+ // Collection-axis dedupe: on a paginated collection operation (`op.pagination`
393
+ // present) the framework-generated SEARCH request DTO carries NESTED
394
+ // `pagination` / `sorting` / `filters` object properties. The canonical wire
395
+ // shape is FLAT (what the frontend HTTP client sends, what the backend
396
+ // controller reads first — the nested forms are fallbacks): the `page` /
397
+ // `limit` / `sort` trio, a free-text `q`, and one parameter per filter field
398
+ // (scalars flat, date ranges as `deepObject`). Exploding the `filters` object
399
+ // verbatim would emit a nested object with no `style: deepObject` metadata, so
400
+ // SDK generators would serialise it wrong. Drop the nested axes here — the flat
401
+ // trio is emitted by `buildPaginationQueryParameters`, and the flat `q` +
402
+ // per-field filters by `buildCollectionFilterQueryParameters`, both on the same
403
+ // operation. The flat trio names are excluded too so a DTO can never collide with
404
+ // them. Ops WITHOUT the pagination contract keep every DTO property untouched.
405
+ const paginationAxisProperties = op.pagination !== undefined
406
+ ? new Set(['pagination', 'sorting', 'filters', 'page', 'limit', 'sort'])
407
+ : new Set();
408
+ return Object.entries(properties)
409
+ .filter(([name]) => !paginationAxisProperties.has(name))
410
+ .map(([name, propertySchema]) => {
411
+ const parameter = {
412
+ name,
413
+ in: 'query',
414
+ required: requiredFields.has(name),
415
+ schema: isPlainObject(propertySchema) ? propertySchema : {},
416
+ };
417
+ if (isPlainObject(propertySchema) && typeof propertySchema.description === 'string') {
418
+ parameter['description'] = propertySchema.description;
419
+ }
420
+ const examples = buildParameterExamples(op, name);
421
+ if (examples !== undefined) {
422
+ parameter['examples'] = examples;
423
+ }
424
+ return parameter;
425
+ });
426
+ }
427
+ /**
428
+ * Projects source-authored request values onto one OpenAPI parameter. Path and
429
+ * query parameters share the same Parameter Object example contract; retaining
430
+ * the source title lets a consumer correlate a concrete id/filter with the
431
+ * business case it was authored for. Response-only examples never participate.
432
+ */
433
+ function buildParameterExamples(op, parameterName) {
434
+ const examples = {};
435
+ for (const example of op.examples ?? []) {
436
+ if (!isPlainObject(example.request) || !Object.prototype.hasOwnProperty.call(example.request, parameterName)) {
437
+ continue;
438
+ }
439
+ examples[example.title] = { value: example.request[parameterName] };
440
+ }
441
+ return Object.keys(examples).length > 0 ? examples : undefined;
442
+ }
443
+ /**
444
+ * Adds matching source-authored values to a generated Parameter Object. This
445
+ * is shared by path parameters, direct DTO-property query parameters and the
446
+ * framework-generated flat collection axes (`q`, filters, pagination).
447
+ */
448
+ function attachParameterExamples(op, parameter) {
449
+ const name = parameter['name'];
450
+ if (typeof name !== 'string') {
451
+ return parameter;
452
+ }
453
+ const examples = buildParameterExamples(op, name);
454
+ return examples === undefined ? parameter : { ...parameter, examples };
455
+ }
456
+ /**
457
+ * Flattens a paginated collection operation's nested `filters` request-DTO object
458
+ * into the FLAT, wire-truthful query parameters the framework actually accepts —
459
+ * the filtering counterpart of the `page` / `limit` / `sort` pagination-trio
460
+ * reshape (`buildPaginationQueryParameters`).
461
+ *
462
+ * The framework's SEARCH request DTO (`createResourceSearchQueryRequestSchema`)
463
+ * always carries a top-level `filters` OBJECT: the free-text `searchRequest`, the
464
+ * `createdAt` / `updatedAt` date-range filters, plus one property per authored
465
+ * filter field. That nested object is NOT the wire shape — the frontend HTTP
466
+ * client sends each filter FLAT (`?status=…`) and free-text as `?q=…`, and the
467
+ * backend controller (`extractCollectionFilters` + `normalizeCollectionQueryRequestData`)
468
+ * reads the flat forms first. This helper re-surfaces the FLAT-serialisable
469
+ * `filters` properties as their own query parameters:
470
+ *
471
+ * - `searchRequest` → the canonical flat `q` string (what the frontend sends and
472
+ * the backend reads before `searchQuery` before the nested `filters.searchRequest`).
473
+ * - any SCALAR property (string / number / boolean / enum) → a plain flat query
474
+ * parameter (`?status=active`), carrying the property's schema (and description).
475
+ * - an OBJECT-valued property (a date range `{ startDate?, endDate? }`) →
476
+ * `style: deepObject, explode: true`, so SDK generators serialise it as
477
+ * `field[startDate]=…&field[endDate]=…` (bracket notation). The backend runs
478
+ * Express 5's default `simple` query parser (which leaves bracketed keys flat),
479
+ * so the controller reconstructs the nested object from those keys —
480
+ * `reconstructBracketedRangeFilters` in the collection-query controller,
481
+ * declaration-gated to actual range fields. Emitting the `deepObject` metadata
482
+ * is the second half of the fix the pagination reshape called out: a nested
483
+ * object in a GET query string needs `style: deepObject`, which the naive DTO
484
+ * explosion never emitted. (A global `extended` query parser was deliberately
485
+ * NOT used — it changes `req.query` shape for every controller and breaks the
486
+ * flat-scalar `req.query.X as string` assumption across auth / SCIM / chart.)
487
+ * - a filter field whose name is ALSO a path parameter of this endpoint → SKIPPED
488
+ * (URL-supplied; see the per-operation note below).
489
+ *
490
+ * Gated on `op.pagination !== undefined` (collection ops only — the same signal
491
+ * the collection-axis dedupe uses), a query verb, and a `filters` object property,
492
+ * so a non-collection GET carrying an unrelated `filters` property keeps its naive
493
+ * explosion untouched. All filter parameters are OPTIONAL. Returns `[]` when the
494
+ * op is not a paginated collection op or declares no `filters` object.
495
+ */
496
+ function buildCollectionFilterQueryParameters(op) {
497
+ if (op.pagination === undefined || !isQueryParameterVerb(op.httpVerb)) {
498
+ return [];
499
+ }
500
+ const schema = op.requestBodySchema;
501
+ if (!isPlainObject(schema) || !isPlainObject(schema.properties)) {
502
+ return [];
503
+ }
504
+ const filtersSchema = schema.properties['filters'];
505
+ if (!isPlainObject(filtersSchema) || !isPlainObject(filtersSchema.properties)) {
506
+ return [];
507
+ }
508
+ const filterProperties = filtersSchema.properties;
509
+ // A filter field that is ALSO a path parameter of THIS endpoint (a parent-scope
510
+ // FK reached via a nested path, e.g. `todoId` on `/…/todos/{todoId}/tasks`) is
511
+ // already supplied by the URL. Emitting it AGAIN as a query parameter is
512
+ // redundant, produces two same-named parameters SDK generators mishandle, and —
513
+ // because the backend merges `{ ...req.params, ...req.query }` — would let the
514
+ // query value silently override the path scope (a tenant-scope footgun). Skip
515
+ // it: the path parameter is authoritative. The skip is PER-OPERATION (keyed on
516
+ // this path's params), so the same filter field is still emitted on a sibling
517
+ // path that does NOT bind the FK, where filtering by it is meaningful.
518
+ const pathParameterNames = new Set(extractPathParameters(op.path));
519
+ const parameters = [];
520
+ // Free-text search → the canonical flat `q` (the frontend sends `?q=`, and the
521
+ // backend reads `q` first). The `filters.searchRequest` DTO property is the
522
+ // nested fallback name, so it is surfaced under the wire name, not verbatim.
523
+ if ('searchRequest' in filterProperties) {
524
+ parameters.push(attachParameterExamples(op, {
525
+ name: 'q',
526
+ in: 'query',
527
+ required: false,
528
+ schema: { type: 'string' },
529
+ description: 'Free-text search query, matched against the operation\'s configured searchable '
530
+ + 'fields. Sent flat as `?q=…` (the wire form the backend reads first).',
531
+ }));
532
+ }
533
+ for (const [name, propertySchema] of Object.entries(filterProperties)) {
534
+ if (name === 'searchRequest' || pathParameterNames.has(name)) {
535
+ continue; // `searchRequest` surfaced as `q` above; path-param FKs are URL-supplied
536
+ }
537
+ const isObjectValued = isPlainObject(propertySchema)
538
+ && (propertySchema.type === 'object' || 'properties' in propertySchema);
539
+ const parameter = { name, in: 'query', required: false };
540
+ // Object-valued filters (date ranges) are irreducibly nested; emit deepObject
541
+ // so SDKs serialise `name[<field>]=…`. The backend's collection-query controller
542
+ // reconstructs the nested object from those bracketed keys (the global query
543
+ // parser stays `simple`); see `reconstructBracketedRangeFilters`.
544
+ if (isObjectValued) {
545
+ parameter['style'] = 'deepObject';
546
+ parameter['explode'] = true;
547
+ }
548
+ parameter['schema'] = isPlainObject(propertySchema) ? propertySchema : {};
549
+ if (isPlainObject(propertySchema) && typeof propertySchema.description === 'string') {
550
+ parameter['description'] = propertySchema.description;
551
+ }
552
+ else if (isObjectValued) {
553
+ // No authored description on the range object — synthesise a truthful hint
554
+ // that names the deepObject bracket form for each sub-field it carries.
555
+ const subKeys = isPlainObject(propertySchema) && isPlainObject(propertySchema.properties)
556
+ ? Object.keys(propertySchema.properties)
557
+ : [];
558
+ const bracketHint = subKeys.length > 0
559
+ ? subKeys.map((subKey) => `\`${name}[${subKey}]=…\``).join(' & ')
560
+ : `\`${name}[<field>]=…\``;
561
+ parameter['description'] =
562
+ `Object-valued filter — serialise each sub-field with deepObject bracket notation (${bracketHint}).`;
563
+ }
564
+ parameters.push(attachParameterExamples(op, parameter));
565
+ }
566
+ return parameters;
567
+ }
568
+ /**
569
+ * Framework-universal pagination defaults, mirrored from the backend
570
+ * repository adapters (`_doList` in both the MongoDB and PostgreSQL resource
571
+ * repositories, `@wildo-ai/saas-backend-lib`): an omitted `page` reads as
572
+ * page 1, an omitted `limit` reads as 20 items per page. Duplicated as
573
+ * literals for the same boundary reason as the enums above (the generator
574
+ * must not import `@wildo-ai/saas-backend-lib`); keep in sync with the
575
+ * repository defaults.
576
+ */
577
+ const PAGINATION_DEFAULT_PAGE = 1;
578
+ const PAGINATION_DEFAULT_LIMIT = 20;
579
+ /**
580
+ * Emits the flat `page` / `limit` / `sort` query parameters for a paginated
581
+ * collection operation (`op.pagination` present — LIST / SEARCH-like; see the
582
+ * projection schema JSDoc). These parameters are FRAMEWORK knowledge: the
583
+ * backend accepts them on every HTTP LIST/SEARCH dispatch but no request DTO
584
+ * declares them, so without this emission the spec documents paginated
585
+ * endpoints with no way to page them (and the `info.description` conventions
586
+ * block would promise parameters the operations never declare).
587
+ *
588
+ * Shapes follow the runtime contract exactly:
589
+ *
590
+ * - `page` — 1-based positive integer, server default 1.
591
+ * - `limit` — positive integer, server default 20, capped at the
592
+ * per-operation `maxLimit` the projector resolved.
593
+ * - `sort` — comma-separated `field:asc` / `field:desc` pairs, highest
594
+ * priority first. With a non-empty `sortableFields` allow-list the backend
595
+ * silently ignores unrecognised fields and defaults to the first allowed
596
+ * field descending; with no allow-list any field applies and the default
597
+ * is `createdAt:desc`. The description states whichever branch is true
598
+ * for this operation, so the docs never promise sorting the backend
599
+ * would drop.
600
+ */
601
+ function buildPaginationQueryParameters(op) {
602
+ const pagination = op.pagination;
603
+ if (pagination === undefined) {
604
+ return [];
605
+ }
606
+ const sortableFields = pagination.sortableFields;
607
+ const sortDescription = sortableFields.length > 0
608
+ ? 'Sort order: comma-separated `field:asc` / `field:desc` pairs, highest priority first '
609
+ + `(e.g. \`${sortableFields[0]}:desc\`). Sortable fields: `
610
+ + `${sortableFields.map((field) => `\`${field}\``).join(', ')}. `
611
+ + `Unrecognised fields are ignored. Defaults to \`${sortableFields[0]}:desc\`.`
612
+ : 'Sort order: comma-separated `field:asc` / `field:desc` pairs, highest priority first '
613
+ + '(e.g. `createdAt:desc`). Defaults to `createdAt:desc` (newest first).';
614
+ return [
615
+ {
616
+ name: 'page',
617
+ in: 'query',
618
+ required: false,
619
+ schema: { type: 'integer', minimum: 1, default: PAGINATION_DEFAULT_PAGE },
620
+ description: '1-based page number of the collection slice.',
621
+ },
622
+ {
623
+ name: 'limit',
624
+ in: 'query',
625
+ required: false,
626
+ schema: { type: 'integer', minimum: 1, maximum: pagination.maxLimit, default: PAGINATION_DEFAULT_LIMIT },
627
+ description: `Maximum number of items per page (up to ${pagination.maxLimit}).`,
628
+ },
629
+ {
630
+ name: 'sort',
631
+ in: 'query',
632
+ required: false,
633
+ schema: { type: 'string' },
634
+ description: sortDescription,
635
+ },
636
+ ].map((parameter) => attachParameterExamples(op, parameter));
637
+ }
638
+ /**
639
+ * HTTP header carrying a complete API key. Read from the lightweight public
640
+ * runtime entrypoint so the generator shares the backend's canonical header
641
+ * vocabulary without importing the saas-models root barrel.
642
+ */
643
+ const API_KEY_AUTHORIZATION_HEADER_NAME = WildoHeaderKeys.AUTHORIZATION;
644
+ /** HTTP header carrying an anonymous-session token. */
645
+ const ANONYMOUS_SESSION_HEADER_NAME = WildoHeaderKeys.ANONYMOUS_SESSION_TOKEN;
646
+ /**
647
+ * Framework-universal OpenAPI `components.securitySchemes`. Every Wildo API
648
+ * authenticates the same ways, so the generator emits these UNCONDITIONALLY —
649
+ * framework knowledge, not app config. Per-operation `security` references them
650
+ * by name (see `buildOperationSecurity`).
651
+ *
652
+ * - `bearerAuth` — JWT access token in `Authorization: Bearer <jwt>`.
653
+ * - `apiKeyAuth` — org-/app-scoped API key (`sk_org_…` / `sk_app_…`) in `Authorization` without a bearer scheme.
654
+ * - `anonymousSessionAuth` — anonymous-session token in `x-anonymous-session-token`
655
+ * (the pre-authentication surface; `APP_ANONYMOUS` operations).
656
+ */
657
+ const SECURITY_SCHEMES = {
658
+ bearerAuth: {
659
+ type: 'http',
660
+ scheme: 'bearer',
661
+ bearerFormat: 'JWT',
662
+ description: 'JWT access token issued by the authentication service. Send as `Authorization: Bearer <token>`.',
663
+ },
664
+ apiKeyAuth: {
665
+ type: 'apiKey',
666
+ in: 'header',
667
+ name: API_KEY_AUTHORIZATION_HEADER_NAME,
668
+ description: 'Organization- or application-scoped API key (`sk_org_…` / `sk_app_…`), sent as the complete value of the `Authorization` header without a `Bearer` scheme.',
669
+ },
670
+ anonymousSessionAuth: {
671
+ type: 'apiKey',
672
+ in: 'header',
673
+ name: ANONYMOUS_SESSION_HEADER_NAME,
674
+ description: 'Anonymous-session token for pre-authentication (anonymous user) access, sent in the `x-anonymous-session-token` header.',
675
+ },
676
+ };
677
+ /**
678
+ * Per-operation OpenAPI `security`, derived ENTIRELY from the source-resolved
679
+ * access document (NOT `primaryScope`, which is the scope/audience axis).
680
+ *
681
+ * - `roles` contains `APP_PUBLIC` → `[]` — explicitly PUBLIC (OpenAPI reads an
682
+ * empty `security` array as "no authentication required"). Public access
683
+ * wins over any other listed role, so the docs/"Try it" don't demand a token
684
+ * for sign-up, public reads, etc.
685
+ * - otherwise the requirements are OR-ed (any one satisfies the request):
686
+ * • `anonymousSessionAuth` when `roles` contains `APP_ANONYMOUS`.
687
+ * • `bearerAuth` (+ `apiKeyAuth`, unless `isApiKeyAccessDisabled`) when the
688
+ * op has ANY non-`APP_ANONYMOUS` role, OR no role at all (an open endpoint
689
+ * for any authenticated user — "app users can list users").
690
+ *
691
+ * So `[APP_ANONYMOUS]` → anonymous-session only; `[APP_ANONYMOUS, APP_USER]` →
692
+ * anonymous-session OR bearer/api-key; `[APP_USER]` / `[]` / `[ORG_OWNER]` →
693
+ * bearer/api-key. `isApiKeyAccessDisabled` removes only the API-key alternative;
694
+ * the HTTP operation remains documented.
695
+ */
696
+ function buildOperationSecurity(op) {
697
+ if (op.access.authenticationMode === OperationProjectionAuthenticationMode.PUBLIC)
698
+ return [];
699
+ const requirements = [];
700
+ if (op.access.authenticationMode === OperationProjectionAuthenticationMode.ANONYMOUS_SESSION
701
+ || op.access.authenticationMode === OperationProjectionAuthenticationMode.ANONYMOUS_OR_AUTHENTICATED) {
702
+ requirements.push({ anonymousSessionAuth: [] });
703
+ }
704
+ if (op.access.authenticationMode === OperationProjectionAuthenticationMode.AUTHENTICATED
705
+ || op.access.authenticationMode === OperationProjectionAuthenticationMode.ANONYMOUS_OR_AUTHENTICATED) {
706
+ requirements.push({ bearerAuth: [] });
707
+ if (!op.isApiKeyAccessDisabled) {
708
+ requirements.push({ apiKeyAuth: [] });
709
+ }
710
+ }
711
+ return requirements;
712
+ }
713
+ /**
714
+ * Builds the OpenAPI 3.1 `paths.<path>.<method>` entry for a single
715
+ * operation. Returns a plain JS object suitable for direct YAML
716
+ * serialisation — no YAML primitives leak past the YAML emitter call.
717
+ */
718
+ function buildOperationObject(op, operationId, errorResponseSchema) {
719
+ const operationObject = {
720
+ operationId,
721
+ summary: op.summary ?? buildDefaultSummary(op),
722
+ tags: [op.resourceIdentifier],
723
+ // Strict framework metadata for consumers that need to join a rendered
724
+ // operation back to its resolved source identity. Standard OpenAPI fields
725
+ // stay standard; this carries only Wildo-specific semantics.
726
+ 'x-wildo': {
727
+ ...op.identity,
728
+ section: op.consumerApiSection,
729
+ roles: [...op.roles],
730
+ access: op.access,
731
+ },
732
+ };
733
+ if (op.description !== undefined) {
734
+ operationObject['description'] = op.description;
735
+ }
736
+ // Auth (Tier-1 framework knowledge). The source projector resolves access
737
+ // semantics once; this generator only transports that fact into standard
738
+ // OpenAPI `security` and the companion `x-wildo` extension.
739
+ const security = buildOperationSecurity(op);
740
+ operationObject['security'] = security;
741
+ if (op.access.authenticationMode !== OperationProjectionAuthenticationMode.PUBLIC && op.access.roleRequirements.length > 0) {
742
+ operationObject['x-required-roles'] = op.access.roleRequirements.map((requirement) => requirement.role);
743
+ }
744
+ const parameters = [];
745
+ const pathParams = extractPathParameters(op.path);
746
+ if (pathParams.length > 0) {
747
+ parameters.push(...pathParams.map((paramName) => attachParameterExamples(op, {
748
+ name: paramName,
749
+ in: 'path',
750
+ required: true,
751
+ schema: { type: 'string' },
752
+ })));
753
+ }
754
+ if (op.stepUpAuthentication !== undefined) {
755
+ parameters.push({
756
+ name: op.stepUpAuthentication.headerName,
757
+ in: 'header',
758
+ required: true,
759
+ schema: { type: 'string' },
760
+ description: `Single-use re-authentication token obtained from POST ${op.stepUpAuthentication.tokenEndpointPath}. `
761
+ + 'Required for this sensitive operation and consumed when the request is authorized.',
762
+ });
763
+ }
764
+ if (op.acceptsIfMatch) {
765
+ parameters.push({
766
+ name: WildoHeaderKeys.IF_MATCH,
767
+ in: 'header',
768
+ required: false,
769
+ schema: {
770
+ type: 'string',
771
+ pattern: '^(?:[0-9]+|"[0-9]+"|W/"[0-9]+")$',
772
+ },
773
+ examples: {
774
+ 'Current resource version': { value: '7' },
775
+ },
776
+ description: 'Optional optimistic-locking version from `_version` in the latest resource response. '
777
+ + 'Send it as a bare integer (`7`), quoted entity tag (`"7"`) or weak entity tag (`W/"7"`). '
778
+ + 'When supplied, the mutation is applied only if that version is still current.',
779
+ });
780
+ }
781
+ if (isQueryParameterVerb(op.httpVerb)) {
782
+ parameters.push(...buildQueryParametersFromRequestSchema(op));
783
+ // Paginated collection ops carry a nested `filters` bag in their request DTO;
784
+ // it was dropped from the naive explosion above and is re-surfaced here as the
785
+ // flat `q` + one parameter per filter field (scalars flat, date ranges as
786
+ // deepObject — the backend controller reconstructs the nested range object).
787
+ parameters.push(...buildCollectionFilterQueryParameters(op));
788
+ }
789
+ else if (op.requestBodySchema !== null) {
790
+ const requestJsonMedia = { schema: op.requestBodySchema };
791
+ const requestExamples = buildRequestExamples(op);
792
+ if (requestExamples !== undefined) {
793
+ requestJsonMedia['examples'] = requestExamples;
794
+ }
795
+ operationObject['requestBody'] = {
796
+ required: true,
797
+ content: {
798
+ 'application/json': requestJsonMedia,
799
+ },
800
+ };
801
+ }
802
+ // Paginated collection ops (LIST / SEARCH-like) accept the framework's flat
803
+ // `page` / `limit` / `sort` query parameters regardless of what their request
804
+ // DTO declares. Appended LAST so operation-specific parameters (`q`, filter
805
+ // fields) read first and the boilerplate trio sits together at the end.
806
+ parameters.push(...buildPaginationQueryParameters(op));
807
+ if (parameters.length > 0) {
808
+ operationObject['parameters'] = parameters;
809
+ }
810
+ // Inject engine metadata (`_version` / `_field_meta`) that the response
811
+ // serializer reattaches onto every materialized resource document but which no
812
+ // resource schema declares. Computed once and shared by the `responses` object
813
+ // and the async callback payload (both describe the same operation result) so
814
+ // structurally identical schemas still content-dedup into one component when
815
+ // hoisted.
816
+ const augmentedResponseBodySchema = augmentResponseSchemaWithEngineMetadata(op.responseBodySchema);
817
+ operationObject['responses'] = buildResponsesObject(op, augmentedResponseBodySchema, errorResponseSchema);
818
+ if (op.variantType === OperationProjectionVariantType.API_CALL_WITH_CALLBACK) {
819
+ // The Zod superRefine on OperationProjectionSchema guarantees
820
+ // callbackPath is defined here — but the runtime check below is
821
+ // defense-in-depth in case a future caller bypasses Zod parsing
822
+ // and feeds the helper a raw projection (which the public API
823
+ // forbids but the helper itself remains pure-function).
824
+ if (op.callbackPath !== undefined) {
825
+ operationObject['callbacks'] = {
826
+ completion: {
827
+ [`{$request.body#/callbackUrl}${op.callbackPath}`]: {
828
+ post: {
829
+ requestBody: {
830
+ required: true,
831
+ content: {
832
+ 'application/json': {
833
+ schema: augmentedResponseBodySchema ?? {},
834
+ },
835
+ },
836
+ },
837
+ responses: {
838
+ '204': { description: 'Callback acknowledged' },
839
+ },
840
+ },
841
+ },
842
+ },
843
+ };
844
+ }
845
+ }
846
+ if (op.idempotent !== undefined) {
847
+ operationObject['x-idempotent'] = op.idempotent;
848
+ }
849
+ if (op.rateLimitNote !== undefined) {
850
+ const rateLimitLine = `**Rate limiting:** ${op.rateLimitNote}`;
851
+ operationObject['description'] = typeof operationObject['description'] === 'string'
852
+ ? `${operationObject['description']}\n\n${rateLimitLine}`
853
+ : rateLimitLine;
854
+ }
855
+ return operationObject;
856
+ }
857
+ /**
858
+ * Client-facing engine-metadata properties that the response serializer
859
+ * REATTACHES onto every materialized resource document AFTER the response DTO
860
+ * parse strips them (`operation-response-serializer.backend.utils` +
861
+ * `services-registry-handler.transformOutput`, both in `@wildo-ai/saas-backend-lib`).
862
+ * They are engine metadata, NOT declared fields of any resource schema — so
863
+ * `operation.responseDto` (and thus the projected `responseBodySchema`) omits
864
+ * them and the generated OpenAPI would under-document the real wire response.
865
+ * We emit them as OPTIONAL, non-required properties so the spec matches what
866
+ * clients actually receive; well-behaved clients already ignore unknown
867
+ * `_`-prefixed fields, this just makes the contract explicit.
868
+ *
869
+ * - `_version` — optimistic-locking counter (a non-negative integer; the
870
+ * stored `_version_db_doc` surfaced to clients, starts at 1 and increments on
871
+ * every write, but can read as `0`). The frontend echoes it back as the
872
+ * `If-Match` request header on inline updates to detect concurrent edits.
873
+ * - `_field_meta` — per-field CRDT causality map used for conflict-free merges
874
+ * of concurrent edits. Opaque to API clients; safe to ignore.
875
+ *
876
+ * Cloned per-injection (spread) so no two schemas share a mutable node.
877
+ */
878
+ const ENGINE_METADATA_PROPERTY_SCHEMAS = {
879
+ _version: {
880
+ type: 'integer',
881
+ description: 'Engine metadata (not a resource field): optimistic-locking version counter. '
882
+ + 'Echo it back as the `If-Match` request header on updates to guard against concurrent edits.',
883
+ },
884
+ _field_meta: {
885
+ type: 'object',
886
+ additionalProperties: true,
887
+ description: 'Engine metadata (not a resource field): per-field CRDT causality map used for '
888
+ + 'conflict-free merges of concurrent edits. Opaque to clients — safe to ignore.',
889
+ },
890
+ };
891
+ /**
892
+ * The structural signature of a MATERIALIZED resource document in a response
893
+ * schema is the `_id` property. The serializer attaches `_version` /
894
+ * `_field_meta` to exactly those objects (repository documents carry the stored
895
+ * `_version_db_doc`, surfaced as `_version`); envelopes (`{ count }`, delete /
896
+ * purge acks), computed-state projections (feature entitlements, lifecycle
897
+ * state), and `context`-mode wrappers have no `_id` and carry no engine
898
+ * metadata. Gating on `_id` keeps the spec truthful in BOTH directions — no
899
+ * under-documentation of real documents, no over-documentation of envelopes.
900
+ * Mutates `objectSchema.properties` in place (the caller owns a fresh clone).
901
+ */
902
+ function injectEngineMetadataIntoResourceDocumentSchema(objectSchema) {
903
+ const properties = objectSchema['properties'];
904
+ if (!isPlainObject(properties) || !('_id' in properties)) {
905
+ return;
906
+ }
907
+ for (const [name, schema] of Object.entries(ENGINE_METADATA_PROPERTY_SCHEMAS)) {
908
+ // Never clobber an authored field of the same name (defensive — no resource
909
+ // declares `_version` / `_field_meta`, but the guard keeps injection
910
+ // idempotent). NOT added to `required`: engine metadata is optional.
911
+ if (!(name in properties)) {
912
+ properties[name] = { ...schema };
913
+ }
914
+ }
915
+ }
916
+ /**
917
+ * Walk the composite wrappers (`oneOf` / `anyOf` / `allOf`) a polymorphic /
918
+ * discriminated-union response produces (`z.toJSONSchema` emits `oneOf` for a
919
+ * `ZodDiscriminatedUnion`) down to the concrete object branches, injecting
920
+ * engine metadata into each branch that is a resource document. A non-composite
921
+ * object is injected directly.
922
+ */
923
+ function injectEngineMetadataIntoEntitySchemas(schema) {
924
+ if (!isPlainObject(schema)) {
925
+ return;
926
+ }
927
+ let isComposite = false;
928
+ for (const compositeKey of ['oneOf', 'anyOf', 'allOf']) {
929
+ const branches = schema[compositeKey];
930
+ if (Array.isArray(branches)) {
931
+ isComposite = true;
932
+ for (const branch of branches) {
933
+ injectEngineMetadataIntoEntitySchemas(branch);
934
+ }
935
+ }
936
+ }
937
+ if (!isComposite) {
938
+ injectEngineMetadataIntoResourceDocumentSchema(schema);
939
+ }
940
+ }
941
+ /**
942
+ * Return a CLONE of an operation's response body schema with the engine-metadata
943
+ * properties injected at the SAME structural positions the response serializer
944
+ * attaches them (`operation-response-serializer.backend.utils`):
945
+ *
946
+ * 1. paginated `{ data: [ … ], pagination }` → each `data[]` item
947
+ * 2. flat array `[ … ]` → each item
948
+ * 3. single object → the object itself
949
+ *
950
+ * (each unwrapped through `oneOf`/`anyOf`/`allOf` for polymorphic resources).
951
+ * The original schema is NEVER mutated — the callback path and any structurally
952
+ * identical schema on another operation must stay pristine so content-dedup
953
+ * (`stableStringify`) still collapses them onto one component. Non-object
954
+ * schemas (`null` / scalar) pass through unchanged.
955
+ */
956
+ function augmentResponseSchemaWithEngineMetadata(responseBodySchema) {
957
+ if (!isPlainObject(responseBodySchema)) {
958
+ return responseBodySchema;
959
+ }
960
+ const clone = structuredClone(responseBodySchema);
961
+ const properties = clone['properties'];
962
+ const dataSchema = isPlainObject(properties) ? properties['data'] : undefined;
963
+ if (isPlainObject(dataSchema) && (dataSchema['type'] === 'array' || 'items' in dataSchema)) {
964
+ // Paginated `{ data: [ … ] }` wrapper — mirror the serializer's `body.data[i]`
965
+ // attach position. (A `context`-mode wrapper's `data[]` item is itself a
966
+ // `{ data: [ … ] }` sub-collection with no `_id`, so it is correctly skipped.)
967
+ injectEngineMetadataIntoEntitySchemas(dataSchema['items']);
968
+ }
969
+ else if (clone['type'] === 'array' || 'items' in clone) {
970
+ injectEngineMetadataIntoEntitySchemas(clone['items']);
971
+ }
972
+ else {
973
+ injectEngineMetadataIntoEntitySchemas(clone);
974
+ }
975
+ return clone;
976
+ }
977
+ /**
978
+ * Builds the OpenAPI `responses` object for an operation: authored success
979
+ * statuses + error scenarios (Tier 3), plus the framework-wide authentication
980
+ * failure derived from the source-resolved access mode. It falls back to the
981
+ * default single `200`/`204` when no success status is authored. Response
982
+ * `examples` are attached to the status they declare via `forStatus`. Multiple
983
+ * error scenarios on the same code merge their `when` prose into one entry's
984
+ * description.
985
+ *
986
+ * `responseBodySchema` is the operation's response body schema ALREADY augmented
987
+ * with engine metadata (see `augmentResponseSchemaWithEngineMetadata`); the
988
+ * caller computes it once so the `responses` object and the async callback
989
+ * payload share the same node.
990
+ */
991
+ function buildResponsesObject(op, responseBodySchema, errorResponseSchema) {
992
+ const responseExamplesByStatus = new Map();
993
+ for (const example of op.examples ?? []) {
994
+ if (example.response === undefined || example.forStatus === undefined) {
995
+ continue;
996
+ }
997
+ const bucket = responseExamplesByStatus.get(example.forStatus) ?? {};
998
+ bucket[example.title] = { value: example.response };
999
+ responseExamplesByStatus.set(example.forStatus, bucket);
1000
+ }
1001
+ const buildSuccessContent = (code) => {
1002
+ if (responseBodySchema === null || code === '204') {
1003
+ return undefined;
1004
+ }
1005
+ const jsonMedia = { schema: responseBodySchema };
1006
+ const examples = responseExamplesByStatus.get(code);
1007
+ if (examples !== undefined) {
1008
+ jsonMedia['examples'] = examples;
1009
+ }
1010
+ return { 'application/json': jsonMedia };
1011
+ };
1012
+ const responses = {};
1013
+ const successStatuses = op.responseStatuses ?? [];
1014
+ if (successStatuses.length > 0) {
1015
+ for (const status of successStatuses) {
1016
+ const entry = { description: status.meaning };
1017
+ const content = buildSuccessContent(status.code);
1018
+ if (content !== undefined) {
1019
+ entry['content'] = content;
1020
+ }
1021
+ responses[status.code] = entry;
1022
+ }
1023
+ }
1024
+ else if (responseBodySchema !== null) {
1025
+ const entry = { description: 'Success' };
1026
+ const content = buildSuccessContent('200');
1027
+ if (content !== undefined) {
1028
+ entry['content'] = content;
1029
+ }
1030
+ responses['200'] = entry;
1031
+ }
1032
+ else {
1033
+ responses['204'] = { description: 'No Content' };
1034
+ }
1035
+ for (const error of op.errorScenarios ?? []) {
1036
+ const description = error.errorCode !== undefined
1037
+ ? `${error.when} (error code: \`${error.errorCode}\`)`
1038
+ : error.when;
1039
+ const errorJsonMedia = { schema: errorResponseSchema };
1040
+ const examples = responseExamplesByStatus.get(error.code);
1041
+ if (examples !== undefined) {
1042
+ errorJsonMedia['examples'] = examples;
1043
+ }
1044
+ const errorContent = { 'application/json': errorJsonMedia };
1045
+ const existing = responses[error.code];
1046
+ if (isPlainObject(existing) && typeof existing['description'] === 'string') {
1047
+ existing['description'] = `${existing['description']}\n\n${description}`;
1048
+ existing['content'] = errorContent;
1049
+ }
1050
+ else {
1051
+ responses[error.code] = {
1052
+ description,
1053
+ content: errorContent,
1054
+ };
1055
+ }
1056
+ }
1057
+ if (op.acceptsIfMatch) {
1058
+ const mergeFrameworkError = (code, description) => {
1059
+ const existing = responses[code];
1060
+ if (isPlainObject(existing) && typeof existing['description'] === 'string') {
1061
+ existing['description'] = `${existing['description']}\n\n${description}`;
1062
+ existing['content'] = { 'application/json': { schema: errorResponseSchema } };
1063
+ return;
1064
+ }
1065
+ responses[code] = {
1066
+ description,
1067
+ content: { 'application/json': { schema: errorResponseSchema } },
1068
+ };
1069
+ };
1070
+ mergeFrameworkError('400', 'The optional `If-Match` header is present but does not contain a non-negative integer resource version.');
1071
+ mergeFrameworkError('409', 'The `If-Match` version no longer matches the current resource version, so the mutation was not applied.');
1072
+ }
1073
+ // Every framework RATE_LIMIT error with a retry delay is serialized by
1074
+ // `ErrorHandlerBackendService` with an HTTP `Retry-After` header. A source-
1075
+ // authored 429 is therefore sufficient to project this protocol contract;
1076
+ // individual resource specifications must not duplicate framework header
1077
+ // vocabulary. The current runtime emits integer delay-seconds, not an HTTP
1078
+ // date, so the Header Object deliberately exposes that narrower shape.
1079
+ const rateLimitResponse = responses['429'];
1080
+ if (isPlainObject(rateLimitResponse)) {
1081
+ rateLimitResponse['headers'] = {
1082
+ 'Retry-After': {
1083
+ description: 'Delay in seconds before the client should retry the request.',
1084
+ schema: { type: 'integer', minimum: 0 },
1085
+ },
1086
+ };
1087
+ }
1088
+ // Authentication happens before the resource operation. The projection's
1089
+ // access mode is the sole truth for which credentials may satisfy it, so the
1090
+ // renderer and each resource specification do not need to re-state the same
1091
+ // 401 contract. A resource-owned 401 is more specific (for example a
1092
+ // step-up flow) and deliberately takes precedence.
1093
+ if (op.access.authenticationMode !== OperationProjectionAuthenticationMode.PUBLIC && responses['401'] === undefined) {
1094
+ const description = (() => {
1095
+ switch (op.access.authenticationMode) {
1096
+ case OperationProjectionAuthenticationMode.ANONYMOUS_SESSION:
1097
+ return 'The anonymous-session token is missing, malformed, expired, or invalid.';
1098
+ case OperationProjectionAuthenticationMode.ANONYMOUS_OR_AUTHENTICATED:
1099
+ return 'No valid anonymous-session token, bearer token, or API key was supplied.';
1100
+ case OperationProjectionAuthenticationMode.AUTHENTICATED:
1101
+ return 'No valid bearer token or API key was supplied.';
1102
+ default: {
1103
+ const exhaustiveAuthenticationMode = op.access.authenticationMode;
1104
+ throw new Error(`OpenAPI generator: unsupported authentication mode ${String(exhaustiveAuthenticationMode)}`);
1105
+ }
1106
+ }
1107
+ })();
1108
+ responses['401'] = {
1109
+ description,
1110
+ content: { 'application/json': { schema: errorResponseSchema } },
1111
+ };
1112
+ }
1113
+ return responses;
1114
+ }
1115
+ /**
1116
+ * Builds the OpenAPI request-body `examples` map from authored examples that
1117
+ * carry a `request` payload. Returns undefined when there are none.
1118
+ */
1119
+ function buildRequestExamples(op) {
1120
+ const examples = {};
1121
+ const pathParameterNames = new Set(extractPathParameters(op.path));
1122
+ for (const example of op.examples ?? []) {
1123
+ if (example.request === undefined) {
1124
+ continue;
1125
+ }
1126
+ // Source examples use one object so the same authored scenario can feed path
1127
+ // parameters and the JSON body. OpenAPI represents those as separate locations:
1128
+ // keep path values on Parameter Objects (via `buildParameterExamples`) and remove
1129
+ // them from the body example. Otherwise a primary-scope id such as
1130
+ // `organizationId` appears twice even when the request DTO correctly owns only
1131
+ // `justification` and `requestedDurationMinutes`.
1132
+ const value = isPlainObject(example.request)
1133
+ ? Object.fromEntries(Object.entries(example.request).filter(([name]) => !pathParameterNames.has(name)))
1134
+ : example.request;
1135
+ examples[example.title] = { value };
1136
+ }
1137
+ return Object.keys(examples).length > 0 ? examples : undefined;
1138
+ }
1139
+ /**
1140
+ * Is this JSON-Schema fragment worth hoisting into `components/schemas`? Only
1141
+ * structured bodies (objects / arrays / composed schemas) earn a named
1142
+ * component + `$ref`; bare scalars (`{ type: 'string' }`) and empty `{}` stay
1143
+ * inline (a `$ref` to a one-line scalar would be pure overhead). Already-`$ref`
1144
+ * fragments are left alone.
1145
+ */
1146
+ function isHoistableSchema(value) {
1147
+ if (!isPlainObject(value) || '$ref' in value) {
1148
+ return false;
1149
+ }
1150
+ return (value['type'] === 'object'
1151
+ || value['type'] === 'array'
1152
+ || 'properties' in value
1153
+ || 'items' in value
1154
+ || 'allOf' in value
1155
+ || 'anyOf' in value
1156
+ || 'oneOf' in value);
1157
+ }
1158
+ /**
1159
+ * Deterministic, key-sorted stringification used to detect structurally
1160
+ * IDENTICAL schemas regardless of key order, so the same shape authored on
1161
+ * different operations (or the same operation reached at multiple paths — the
1162
+ * `Via<…>` variants) collapses onto ONE component.
1163
+ */
1164
+ function stableStringify(value) {
1165
+ if (Array.isArray(value)) {
1166
+ return `[${value.map(stableStringify).join(',')}]`;
1167
+ }
1168
+ if (isPlainObject(value)) {
1169
+ return `{${Object.keys(value)
1170
+ .sort()
1171
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
1172
+ .join(',')}}`;
1173
+ }
1174
+ return JSON.stringify(value) ?? 'null';
1175
+ }
1176
+ /**
1177
+ * Component name for an operation's request/response body. Mirrors the
1178
+ * verb-first operationId namer (shares `pascal`): `<Resource><BaseVerb><Quals><Role>`,
1179
+ * e.g. `TodosListResponse`, `TodosCreateRequest`, `TodosListSummaryResponse`,
1180
+ * `TodosChangeStatusBulkRequest`. Schema-affecting qualifiers (`summary` /
1181
+ * `context` data-mode, `BULK`) ARE included; the multi-path `Via<…>`
1182
+ * disambiguator is STRIPPED because it never changes the body shape — so the
1183
+ * many URL variants of one operation share a single model.
1184
+ */
1185
+ function componentSchemaName(op, role) {
1186
+ const qualifierTail = op.operationKey.startsWith(op.baseOperationIdentifier)
1187
+ ? op.operationKey.slice(op.baseOperationIdentifier.length)
1188
+ : '';
1189
+ const schemaQualifiers = qualifierTail
1190
+ .split('_')
1191
+ .filter((q) => q.length > 0 && !q.startsWith('Via'))
1192
+ .map(pascal)
1193
+ .join('');
1194
+ return `${pascal(op.resourceIdentifier)}${pascal(op.baseOperationIdentifier)}${schemaQualifiers}${role}`;
1195
+ }
1196
+ function createSchemaRegistry() {
1197
+ const nameBySemanticIdentityAndContent = new Map();
1198
+ const contentByName = new Map();
1199
+ const schemas = {};
1200
+ return {
1201
+ ref(schema, desiredName) {
1202
+ if (!isHoistableSchema(schema)) {
1203
+ return schema;
1204
+ }
1205
+ const canonical = stableStringify(schema);
1206
+ const semanticIdentityAndContent = `${desiredName}\u0000${canonical}`;
1207
+ const existingName = nameBySemanticIdentityAndContent.get(semanticIdentityAndContent);
1208
+ if (existingName !== undefined) {
1209
+ return { $ref: `#/components/schemas/${existingName}` };
1210
+ }
1211
+ let name = desiredName;
1212
+ let suffix = 2;
1213
+ while (contentByName.has(name) && contentByName.get(name) !== canonical) {
1214
+ name = `${desiredName}${suffix}`;
1215
+ suffix += 1;
1216
+ }
1217
+ nameBySemanticIdentityAndContent.set(semanticIdentityAndContent, name);
1218
+ contentByName.set(name, canonical);
1219
+ schemas[name] = schema;
1220
+ return { $ref: `#/components/schemas/${name}` };
1221
+ },
1222
+ schemas() {
1223
+ return schemas;
1224
+ },
1225
+ };
1226
+ }
1227
+ /**
1228
+ * Replace an operation object's inline request/response BODY schemas with
1229
+ * `$ref`s into `components/schemas` (via `registry`). Walks only the
1230
+ * `application/json` body slots — `requestBody` and each `responses[*]` with
1231
+ * content; query `parameters` (GET/DELETE) and the rare `callbacks` block stay
1232
+ * inline. All success statuses share one response schema object, so they
1233
+ * resolve to the same `$ref`. Mutates `operationObject` in place.
1234
+ */
1235
+ function hoistOperationSchemas(operationObject, op, registry) {
1236
+ const requestBody = operationObject['requestBody'];
1237
+ if (isPlainObject(requestBody) && isPlainObject(requestBody['content'])) {
1238
+ const media = requestBody['content']['application/json'];
1239
+ if (isPlainObject(media) && isHoistableSchema(media['schema'])) {
1240
+ media['schema'] = registry.ref(media['schema'], componentSchemaName(op, 'Request'));
1241
+ }
1242
+ }
1243
+ const responses = operationObject['responses'];
1244
+ if (isPlainObject(responses)) {
1245
+ for (const entry of Object.values(responses)) {
1246
+ if (!isPlainObject(entry) || !isPlainObject(entry['content'])) {
1247
+ continue;
1248
+ }
1249
+ const media = entry['content']['application/json'];
1250
+ if (isPlainObject(media) && isHoistableSchema(media['schema'])) {
1251
+ media['schema'] = registry.ref(media['schema'], componentSchemaName(op, 'Response'));
1252
+ }
1253
+ }
1254
+ }
1255
+ }
1256
+ /**
1257
+ * Framework-universal API conventions, emitted as the trailing section of every
1258
+ * `info.description`. These describe behaviour that is TRUE OF EVERY Wildo API
1259
+ * (engine-generic application behavior, not Wildo author guidance or app
1260
+ * configuration), so the reusable generator authors them once here. Grounded
1261
+ * in the actual generated spec: the three security schemes (see
1262
+ * `SECURITY_SCHEMES`), the `{ data, pagination }` list envelope, the
1263
+ * `x-idempotent` extension, and standard HTTP error codes. Rate-limit response
1264
+ * headers are deliberately NOT claimed — they are not emitted in the spec.
1265
+ */
1266
+ const API_CONVENTIONS_MARKDOWN = [
1267
+ '## API conventions',
1268
+ '',
1269
+ '### Authentication',
1270
+ '',
1271
+ 'Each operation lists the credentials it accepts under **Security**:',
1272
+ '',
1273
+ '- **Bearer token** — a JWT access token sent as `Authorization: Bearer <token>`.',
1274
+ '- **API key** — an organization- or application-scoped key (`sk_org_…` / `sk_app_…`) sent as the complete value of the `Authorization` header without a `Bearer` scheme.',
1275
+ '- **Anonymous session** — an anonymous-session token sent in `x-anonymous-session-token`, for pre-authentication operations.',
1276
+ '',
1277
+ 'Operations with no security requirement are public.',
1278
+ '',
1279
+ '### Base URL',
1280
+ '',
1281
+ 'Pick a base URL from the **Servers** dropdown and prepend it to each operation path (paths already include the `/api/v1` mount).',
1282
+ '',
1283
+ '### Pagination',
1284
+ '',
1285
+ 'List and search operations return `{ "data": [ … ], "pagination": { "page", "limit", "total", "totalPages" } }`. Use the `page` and `limit` query parameters to page through results, and `sort` (comma-separated `field:asc` / `field:desc` pairs, highest priority first) to order them. Each operation documents its per-page maximum and sortable fields on the parameters themselves.',
1286
+ '',
1287
+ '### Filtering',
1288
+ '',
1289
+ 'Search operations accept a free-text `q` query parameter and one query parameter per configured filter field (for example `?status=active`). Range filters (dates) are object-valued: pass them with `deepObject` bracket notation — `createdAt[startDate]=<ISO>&createdAt[endDate]=<ISO>`. Each operation documents its available filter fields on the parameters themselves.',
1290
+ '',
1291
+ '### Idempotency',
1292
+ '',
1293
+ 'Operations flagged **idempotent** (the `x-idempotent` extension) are safe to retry — repeating the request has the same effect as making it once.',
1294
+ '',
1295
+ '### Errors',
1296
+ '',
1297
+ 'Errors use standard HTTP status codes. Each operation documents the specific failures it can return (for example `400`, `403`, `404`, `409`) and the condition that triggers each.',
1298
+ ].join('\n');
1299
+ /**
1300
+ * Compose the OpenAPI `info.description` (the API reference landing page): the
1301
+ * app's authored `apiOverview` (when present) followed by the framework-universal
1302
+ * `API_CONVENTIONS_MARKDOWN`. The conventions are ALWAYS emitted, so the landing
1303
+ * page is never blank — the overview is pure enrichment on top.
1304
+ */
1305
+ function buildInfoDescription(input) {
1306
+ const overview = input.apiOverview?.trim();
1307
+ return overview && overview.length > 0
1308
+ ? `${overview}\n\n${API_CONVENTIONS_MARKDOWN}`
1309
+ : API_CONVENTIONS_MARKDOWN;
1310
+ }
1311
+ /**
1312
+ * Produces the display label for an OpenAPI resource tag without changing the
1313
+ * stable tag identity used by generated clients and operation metadata.
1314
+ *
1315
+ * The identity remains the resource identifier (`organizationApiKeys`), while
1316
+ * the native Docusaurus reference can present a human-scannable resource
1317
+ * heading (`Organization Api Keys`). Applications can later supply richer
1318
+ * product terminology without making that editorial concern part of the wire
1319
+ * contract.
1320
+ */
1321
+ function buildResourceTagDisplayName(resourceIdentifier) {
1322
+ return splitWords(resourceIdentifier)
1323
+ .map((word) => apiReferenceDisplayWord(word, true))
1324
+ .join(' ');
1325
+ }
1326
+ /**
1327
+ * Builds the full OpenAPI 3.1 document object for a single section.
1328
+ * Returns a plain JS object — the YAML emitter is a separate concern.
1329
+ *
1330
+ * `paths` are nested by template path; multiple HTTP verbs on the
1331
+ * same path are merged into a single path-item object as the OpenAPI
1332
+ * spec requires. Body schemas are hoisted into `components/schemas`
1333
+ * (content-deduped, `$ref`-linked) by a per-section `SchemaRegistry`.
1334
+ */
1335
+ function buildOpenApiDocument(section, operations, input) {
1336
+ const sortedOps = sortOperationsForDeterministicOutput(operations);
1337
+ const titleBase = input.publicMarketingTitle ?? input.appDisplayName;
1338
+ // `organization` / `application` are internal scope partitions. The API
1339
+ // reference must name the consumer job instead: organization-scoped tenant
1340
+ // operations are the normal API, whereas application-scoped operations are
1341
+ // the privileged administration surface.
1342
+ const sectionLabel = section === OpenApiSection.API_REFERENCE
1343
+ ? 'API reference'
1344
+ : 'Application administration API reference';
1345
+ const paths = {};
1346
+ const ownershipByPathAndVerb = new Map();
1347
+ const tagSet = new Set();
1348
+ // operationId must be unique within the document. The verb-first builder is
1349
+ // unique by construction for almost every operation, but a few resources with
1350
+ // deeply self-nested URL families (e.g. `applications` via app-metadata /
1351
+ // app-versions) reduce to the same semantic `Via<…>` and would otherwise
1352
+ // collide. Disambiguate genuine collisions with a stable numeric suffix: the
1353
+ // first occurrence (in deterministic sort order) keeps the clean name, later
1354
+ // ones get `2`, `3`, … — applied here rather than in the projector because
1355
+ // uniqueness is a per-document (per-section) property.
1356
+ const operationIdUseCount = new Map();
1357
+ const schemaRegistry = createSchemaRegistry();
1358
+ // The schema comes from the same model the backend error handler serializes.
1359
+ // Register before per-operation hoisting so every non-success response shares
1360
+ // the stable, named component instead of producing resource-local copies.
1361
+ const errorResponseSchema = schemaRegistry.ref(input.errorResponseSchema, 'ErrorResponse');
1362
+ for (const op of sortedOps) {
1363
+ if (paths[op.path] === undefined) {
1364
+ paths[op.path] = {};
1365
+ }
1366
+ const collisionKey = `${op.path}::${op.httpVerb}`;
1367
+ const existingOwner = ownershipByPathAndVerb.get(collisionKey);
1368
+ if (existingOwner !== undefined) {
1369
+ throw new Error(`OpenAPI generator: duplicate ${op.httpVerb.toUpperCase()} ${op.path} in `
1370
+ + `${section}. Existing operation ${existingOwner} collides with `
1371
+ + `${op.resourceIdentifier}.${op.operationKey}. Each OpenAPI path+verb pair must be unique.`);
1372
+ }
1373
+ const baseOperationId = buildOperationId(op);
1374
+ const priorUses = operationIdUseCount.get(baseOperationId) ?? 0;
1375
+ operationIdUseCount.set(baseOperationId, priorUses + 1);
1376
+ const operationId = priorUses === 0 ? baseOperationId : `${baseOperationId}${priorUses + 1}`;
1377
+ const operationObject = buildOperationObject(op, operationId, errorResponseSchema);
1378
+ hoistOperationSchemas(operationObject, op, schemaRegistry);
1379
+ paths[op.path][op.httpVerb] = operationObject;
1380
+ ownershipByPathAndVerb.set(collisionKey, `${op.resourceIdentifier}.${op.operationKey}`);
1381
+ tagSet.add(op.resourceIdentifier);
1382
+ }
1383
+ // Tier-2: per-resource tag descriptions (resource business purpose). The
1384
+ // tag set is derived from the section's operations above; descriptions are
1385
+ // looked up by tag name from the input's resourceTags (order-independent).
1386
+ // Cleaned of implementation-only noise here (this is the surface the projector
1387
+ // does NOT route through `buildOperationDocFromSpec`), so the resource group
1388
+ // blurbs read identically to the per-operation prose. Tags whose description
1389
+ // cleans to empty are dropped (treated as having no description).
1390
+ const resourceTagsByName = new Map();
1391
+ for (const tag of input.resourceTags ?? []) {
1392
+ if (resourceTagsByName.has(tag.name)) {
1393
+ throw new Error(`OpenAPI generator: duplicate resource tag descriptor for '${tag.name}'.`);
1394
+ }
1395
+ resourceTagsByName.set(tag.name, tag);
1396
+ }
1397
+ return {
1398
+ openapi: '3.1.0',
1399
+ info: {
1400
+ title: `${titleBase} ${sectionLabel}`,
1401
+ description: buildInfoDescription(input),
1402
+ version: input.supportedApiVersion,
1403
+ },
1404
+ // `servers` only when the app enriched `apiServers` (deployment URLs are
1405
+ // app/environment knowledge — see WildoTechnicalDocConfig.apiServers).
1406
+ // Omitted → back-compatible with the pre-servers output.
1407
+ ...(input.servers && input.servers.length > 0 ? { servers: input.servers } : {}),
1408
+ tags: Array.from(tagSet)
1409
+ .sort((a, b) => a.localeCompare(b))
1410
+ .map((tagName) => {
1411
+ const descriptor = resourceTagsByName.get(tagName);
1412
+ const description = descriptor?.description === undefined
1413
+ ? undefined
1414
+ : stripImplementationNoise(descriptor.description);
1415
+ const resourceMetadata = {
1416
+ contractVersion: OPENAPI_RESOURCE_IDENTITY_CONTRACT_VERSION,
1417
+ resourceRef: `technical-documentation:resource/${tagName}`,
1418
+ ...(descriptor?.lifecycleRole === undefined ? {} : { lifecycleRole: stripImplementationNoise(descriptor.lifecycleRole) }),
1419
+ ...(descriptor?.relationships === undefined ? {} : { relationships: descriptor.relationships }),
1420
+ ...(descriptor?.category === undefined ? {} : { category: descriptor.category }),
1421
+ };
1422
+ const tag = {
1423
+ name: tagName,
1424
+ 'x-displayName': buildResourceTagDisplayName(tagName),
1425
+ 'x-wildo': resourceMetadata,
1426
+ };
1427
+ return description !== undefined && description.length > 0 ? { ...tag, description } : tag;
1428
+ }),
1429
+ paths,
1430
+ // `components.schemas` = hoisted, content-deduped request/response body
1431
+ // models (named `<Resource><Verb><Quals>Request|Response`); `securitySchemes`
1432
+ // = framework-universal auth, referenced by each op's `security`.
1433
+ components: {
1434
+ ...(Object.keys(schemaRegistry.schemas()).length > 0
1435
+ ? { schemas: schemaRegistry.schemas() }
1436
+ : {}),
1437
+ securitySchemes: SECURITY_SCHEMES,
1438
+ },
1439
+ };
1440
+ }
1441
+ /**
1442
+ * Counts the unique `resourceIdentifier` values among a set of
1443
+ * operations. Used to populate `OpenApiSectionOutput.resourceCount`.
1444
+ */
1445
+ function countDistinctResources(operations) {
1446
+ const resources = new Set();
1447
+ for (const op of operations) {
1448
+ resources.add(op.resourceIdentifier);
1449
+ }
1450
+ return resources.size;
1451
+ }
1452
+ /**
1453
+ * Builds a single section's output entry. Returns `null` when the
1454
+ * section is empty so the caller can omit it from `sections[]` per
1455
+ * K-2.
1456
+ */
1457
+ function buildSectionOutput(section, operations, input) {
1458
+ if (operations.length === 0) {
1459
+ return null;
1460
+ }
1461
+ const document = buildOpenApiDocument(section, operations, input);
1462
+ return {
1463
+ section,
1464
+ operationCount: operations.length,
1465
+ resourceCount: countDistinctResources(operations),
1466
+ document,
1467
+ };
1468
+ }
1469
+ /**
1470
+ * Refuses a generator change that drops, duplicates or rewrites a resolved HTTP
1471
+ * operation between the source projection and the final OpenAPI documents.
1472
+ *
1473
+ * This is intentionally performed against the emitted `x-wildo` identity,
1474
+ * rather than operationId or path iteration order. Those are presentation and
1475
+ * OpenAPI container details respectively; the source tuple is the contract.
1476
+ */
1477
+ function assertGeneratedOperationConservation(operations, sections) {
1478
+ const identityKey = (identity) => JSON.stringify([
1479
+ identity['contractVersion'],
1480
+ identity['resourceRef'],
1481
+ identity['operationFamilyRef'],
1482
+ identity['operationVariantRef'],
1483
+ identity['variantType'],
1484
+ identity['httpVerb'],
1485
+ identity['path'],
1486
+ identity['section'],
1487
+ ]);
1488
+ const expected = new Set();
1489
+ for (const operation of operations) {
1490
+ const key = identityKey({ ...operation.identity, section: operation.consumerApiSection });
1491
+ if (expected.has(key))
1492
+ throw new Error(`OpenAPI generator: duplicate source operation identity ${key}`);
1493
+ expected.add(key);
1494
+ }
1495
+ const actual = new Set();
1496
+ for (const section of sections) {
1497
+ const paths = section.document['paths'];
1498
+ if (!isPlainObject(paths))
1499
+ throw new Error(`OpenAPI generator: ${section.section} document has no paths object`);
1500
+ for (const pathItem of Object.values(paths)) {
1501
+ if (!isPlainObject(pathItem))
1502
+ continue;
1503
+ for (const operationObject of Object.values(pathItem)) {
1504
+ if (!isPlainObject(operationObject))
1505
+ continue;
1506
+ const metadata = operationObject['x-wildo'];
1507
+ if (metadata === undefined)
1508
+ continue;
1509
+ if (!isPlainObject(metadata))
1510
+ throw new Error('OpenAPI generator: x-wildo operation metadata must be an object');
1511
+ const key = identityKey(metadata);
1512
+ if (actual.has(key))
1513
+ throw new Error(`OpenAPI generator: duplicate emitted operation identity ${key}`);
1514
+ actual.add(key);
1515
+ }
1516
+ }
1517
+ }
1518
+ if (actual.size !== expected.size || [...expected].some((key) => !actual.has(key))) {
1519
+ throw new Error('OpenAPI generator: resolved HTTP operation conservation failed between source projection and OpenAPI output');
1520
+ }
1521
+ }
1522
+ /**
1523
+ * Public entry point — single function the companion controller
1524
+ * calls.
1525
+ *
1526
+ * Steps:
1527
+ * 1. Parse the input (defense-in-depth — the controller validates
1528
+ * against `OpenApiGenerationInputSchema` already, but we re-parse
1529
+ * here so direct callers can't bypass validation).
1530
+ * 2. Assert URL-bearing variants without treating authentication-mode flags
1531
+ * as eligibility filters.
1532
+ * 3. Group by the explicit source-resolved consumer section.
1533
+ * 4. Build one semantic OpenAPI document per non-empty section.
1534
+ * 5. Drop empty sections (K-2).
1535
+ *
1536
+ * @param rawInput The unparsed input — typically the deserialised
1537
+ * wire payload from the introspection subprocess hop.
1538
+ * @returns The `OpenApiGenerationOutput` ready to be returned by the
1539
+ * companion controller.
1540
+ */
1541
+ export function generateOpenApiDocuments(rawInput) {
1542
+ const input = OpenApiGenerationInputSchema.parse(rawInput);
1543
+ const apiOps = filterApiCallOperations(input.operations);
1544
+ const split = groupOperationsByConsumerApiSection(apiOps);
1545
+ const sectionOrder = [
1546
+ OpenApiSection.API_REFERENCE,
1547
+ OpenApiSection.APPLICATION_ADMINISTRATION_API_REFERENCE,
1548
+ ];
1549
+ const sections = [];
1550
+ for (const section of sectionOrder) {
1551
+ const sectionOutput = buildSectionOutput(section, split[section], input);
1552
+ if (sectionOutput !== null) {
1553
+ sections.push(sectionOutput);
1554
+ }
1555
+ }
1556
+ assertGeneratedOperationConservation(apiOps, sections);
1557
+ return {
1558
+ generatedAt: new Date().toISOString(),
1559
+ sections,
1560
+ };
1561
+ }
1562
+ //# sourceMappingURL=openapi-generator.js.map