@topogram/cli 0.3.34

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 (257) hide show
  1. package/ARCHITECTURE.md +67 -0
  2. package/CHANGELOG.md +240 -0
  3. package/README.md +223 -0
  4. package/package.json +51 -0
  5. package/src/adoption/index.js +3 -0
  6. package/src/adoption/plan.js +702 -0
  7. package/src/adoption/reporting.js +464 -0
  8. package/src/adoption/review-groups.js +313 -0
  9. package/src/agent-ops/query-builders.js +5012 -0
  10. package/src/archive/archive.js +141 -0
  11. package/src/archive/compact.js +26 -0
  12. package/src/archive/jsonl.js +70 -0
  13. package/src/archive/resolver-bridge.js +82 -0
  14. package/src/archive/schema.js +87 -0
  15. package/src/archive/unarchive.js +108 -0
  16. package/src/catalog.js +752 -0
  17. package/src/cli/catalog-alias.js +166 -0
  18. package/src/cli.js +9738 -0
  19. package/src/component-behavior.js +173 -0
  20. package/src/example-implementation.js +91 -0
  21. package/src/format.js +19 -0
  22. package/src/generator/adapters.d.ts +4 -0
  23. package/src/generator/adapters.js +325 -0
  24. package/src/generator/api.d.ts +1 -0
  25. package/src/generator/api.js +1196 -0
  26. package/src/generator/check.js +355 -0
  27. package/src/generator/component-conformance.js +767 -0
  28. package/src/generator/components.js +39 -0
  29. package/src/generator/context/bundle.js +291 -0
  30. package/src/generator/context/diff.js +256 -0
  31. package/src/generator/context/digest.js +182 -0
  32. package/src/generator/context/domain-coverage.js +94 -0
  33. package/src/generator/context/domain-page.js +137 -0
  34. package/src/generator/context/index.js +42 -0
  35. package/src/generator/context/report.js +121 -0
  36. package/src/generator/context/shared.js +1397 -0
  37. package/src/generator/context/slice.js +703 -0
  38. package/src/generator/context/task-mode.js +466 -0
  39. package/src/generator/docs.js +327 -0
  40. package/src/generator/index.js +161 -0
  41. package/src/generator/native/parity-bundle.js +311 -0
  42. package/src/generator/output.js +300 -0
  43. package/src/generator/registry.js +482 -0
  44. package/src/generator/runtime/app-bundle.js +456 -0
  45. package/src/generator/runtime/bundle-shared.js +166 -0
  46. package/src/generator/runtime/compile-check.js +163 -0
  47. package/src/generator/runtime/deployment.js +287 -0
  48. package/src/generator/runtime/environment.js +635 -0
  49. package/src/generator/runtime/index.js +32 -0
  50. package/src/generator/runtime/runtime-check.js +554 -0
  51. package/src/generator/runtime/shared.js +515 -0
  52. package/src/generator/runtime/smoke.js +219 -0
  53. package/src/generator/schema.js +204 -0
  54. package/src/generator/sdlc/board.js +66 -0
  55. package/src/generator/sdlc/doc-page.js +53 -0
  56. package/src/generator/sdlc/index.js +23 -0
  57. package/src/generator/sdlc/release-notes.js +62 -0
  58. package/src/generator/sdlc/traceability-matrix.js +65 -0
  59. package/src/generator/shared.js +29 -0
  60. package/src/generator/surfaces/contracts.js +146 -0
  61. package/src/generator/surfaces/databases/contract.js +40 -0
  62. package/src/generator/surfaces/databases/index.js +84 -0
  63. package/src/generator/surfaces/databases/lifecycle-shared.d.ts +1 -0
  64. package/src/generator/surfaces/databases/lifecycle-shared.js +612 -0
  65. package/src/generator/surfaces/databases/migration-plan.js +281 -0
  66. package/src/generator/surfaces/databases/postgres/capabilities.js +14 -0
  67. package/src/generator/surfaces/databases/postgres/drizzle.js +99 -0
  68. package/src/generator/surfaces/databases/postgres/index.js +9 -0
  69. package/src/generator/surfaces/databases/postgres/lifecycle.js +16 -0
  70. package/src/generator/surfaces/databases/postgres/prisma.js +159 -0
  71. package/src/generator/surfaces/databases/postgres/sql-migration.js +102 -0
  72. package/src/generator/surfaces/databases/postgres/sql-schema.js +34 -0
  73. package/src/generator/surfaces/databases/shared.d.ts +1 -0
  74. package/src/generator/surfaces/databases/shared.js +350 -0
  75. package/src/generator/surfaces/databases/snapshot.js +96 -0
  76. package/src/generator/surfaces/databases/sqlite/capabilities.js +14 -0
  77. package/src/generator/surfaces/databases/sqlite/index.js +8 -0
  78. package/src/generator/surfaces/databases/sqlite/lifecycle.js +16 -0
  79. package/src/generator/surfaces/databases/sqlite/prisma.js +143 -0
  80. package/src/generator/surfaces/databases/sqlite/sql-migration.js +65 -0
  81. package/src/generator/surfaces/databases/sqlite/sql-schema.js +27 -0
  82. package/src/generator/surfaces/index.js +25 -0
  83. package/src/generator/surfaces/native/swiftui-app.js +38 -0
  84. package/src/generator/surfaces/native/swiftui-templates/Package.swift.txt +20 -0
  85. package/src/generator/surfaces/native/swiftui-templates/README.generated.md +26 -0
  86. package/src/generator/surfaces/native/swiftui-templates/runtime/DynamicScreens.swift +682 -0
  87. package/src/generator/surfaces/native/swiftui-templates/runtime/TodoAPIClient.swift +156 -0
  88. package/src/generator/surfaces/native/swiftui-templates/runtime/TodoSwiftUIApp.swift +44 -0
  89. package/src/generator/surfaces/native/swiftui-templates/runtime/Visibility.swift +183 -0
  90. package/src/generator/surfaces/services/express.d.ts +1 -0
  91. package/src/generator/surfaces/services/express.js +766 -0
  92. package/src/generator/surfaces/services/hono.d.ts +1 -0
  93. package/src/generator/surfaces/services/hono.js +204 -0
  94. package/src/generator/surfaces/services/index.js +42 -0
  95. package/src/generator/surfaces/services/persistence-wiring.js +240 -0
  96. package/src/generator/surfaces/services/runtime-helpers.js +631 -0
  97. package/src/generator/surfaces/services/server-contract.js +80 -0
  98. package/src/generator/surfaces/services/stateless.d.ts +1 -0
  99. package/src/generator/surfaces/services/stateless.js +97 -0
  100. package/src/generator/surfaces/shared.js +64 -0
  101. package/src/generator/surfaces/web/api-client.js +1 -0
  102. package/src/generator/surfaces/web/forms.js +1 -0
  103. package/src/generator/surfaces/web/index.d.ts +2 -0
  104. package/src/generator/surfaces/web/index.js +53 -0
  105. package/src/generator/surfaces/web/react-components.js +248 -0
  106. package/src/generator/surfaces/web/react.js +538 -0
  107. package/src/generator/surfaces/web/routes.js +1 -0
  108. package/src/generator/surfaces/web/screens.js +1 -0
  109. package/src/generator/surfaces/web/shared.js +369 -0
  110. package/src/generator/surfaces/web/sveltekit-actions.js +28 -0
  111. package/src/generator/surfaces/web/sveltekit-components.js +234 -0
  112. package/src/generator/surfaces/web/sveltekit.js +426 -0
  113. package/src/generator/surfaces/web/ui-web-contract.js +65 -0
  114. package/src/generator/surfaces/web/vanilla.js +239 -0
  115. package/src/generator/verification.js +84 -0
  116. package/src/generator.js +1 -0
  117. package/src/import/core/context.js +52 -0
  118. package/src/import/core/contracts.js +23 -0
  119. package/src/import/core/registry.js +81 -0
  120. package/src/import/core/runner.js +646 -0
  121. package/src/import/core/shared.js +910 -0
  122. package/src/import/enrichers/auth-session.js +18 -0
  123. package/src/import/enrichers/django-rest.js +226 -0
  124. package/src/import/enrichers/doc-linking.js +20 -0
  125. package/src/import/enrichers/rails-controllers.js +246 -0
  126. package/src/import/enrichers/rails-models.js +130 -0
  127. package/src/import/enrichers/workflow-target-state.js +10 -0
  128. package/src/import/extractors/api/aspnet-core.js +304 -0
  129. package/src/import/extractors/api/django-routes.js +318 -0
  130. package/src/import/extractors/api/express.js +154 -0
  131. package/src/import/extractors/api/fastify.js +371 -0
  132. package/src/import/extractors/api/flutter-dio.js +135 -0
  133. package/src/import/extractors/api/generic-route-fallback.js +90 -0
  134. package/src/import/extractors/api/graphql-code-first.js +565 -0
  135. package/src/import/extractors/api/graphql-sdl.js +309 -0
  136. package/src/import/extractors/api/jaxrs.js +303 -0
  137. package/src/import/extractors/api/micronaut.js +213 -0
  138. package/src/import/extractors/api/next-route.js +50 -0
  139. package/src/import/extractors/api/next-server-action.js +51 -0
  140. package/src/import/extractors/api/nextauth.js +52 -0
  141. package/src/import/extractors/api/openapi-code.js +242 -0
  142. package/src/import/extractors/api/openapi.js +232 -0
  143. package/src/import/extractors/api/rails-routes.js +230 -0
  144. package/src/import/extractors/api/react-native-repository.js +128 -0
  145. package/src/import/extractors/api/retrofit.js +103 -0
  146. package/src/import/extractors/api/spring-web.js +372 -0
  147. package/src/import/extractors/api/swift-webapi.js +116 -0
  148. package/src/import/extractors/api/trpc.js +212 -0
  149. package/src/import/extractors/db/django-models.js +232 -0
  150. package/src/import/extractors/db/dotnet-models.js +93 -0
  151. package/src/import/extractors/db/drizzle.js +242 -0
  152. package/src/import/extractors/db/ef-core.js +221 -0
  153. package/src/import/extractors/db/flutter-entities.js +120 -0
  154. package/src/import/extractors/db/jpa.js +120 -0
  155. package/src/import/extractors/db/liquibase.js +180 -0
  156. package/src/import/extractors/db/mybatis-xml.js +145 -0
  157. package/src/import/extractors/db/prisma.js +185 -0
  158. package/src/import/extractors/db/rails-schema.js +175 -0
  159. package/src/import/extractors/db/react-native-entities.js +95 -0
  160. package/src/import/extractors/db/room.js +193 -0
  161. package/src/import/extractors/db/snapshot.js +112 -0
  162. package/src/import/extractors/db/sql.js +180 -0
  163. package/src/import/extractors/db/swiftdata.js +137 -0
  164. package/src/import/extractors/ui/android-compose.js +230 -0
  165. package/src/import/extractors/ui/backend-only.js +70 -0
  166. package/src/import/extractors/ui/blazor.js +227 -0
  167. package/src/import/extractors/ui/flutter-screens.js +152 -0
  168. package/src/import/extractors/ui/maui-xaml.js +135 -0
  169. package/src/import/extractors/ui/next-app-router.js +83 -0
  170. package/src/import/extractors/ui/next-pages-router.js +141 -0
  171. package/src/import/extractors/ui/razor-pages.js +181 -0
  172. package/src/import/extractors/ui/react-native-screens.js +166 -0
  173. package/src/import/extractors/ui/react-router.js +139 -0
  174. package/src/import/extractors/ui/sveltekit.js +123 -0
  175. package/src/import/extractors/ui/swiftui.js +193 -0
  176. package/src/import/extractors/ui/uikit.js +175 -0
  177. package/src/import/extractors/verification/generic.js +290 -0
  178. package/src/import/extractors/workflows/generic.js +137 -0
  179. package/src/import/index.js +7 -0
  180. package/src/import/provenance.js +158 -0
  181. package/src/new-project.js +2107 -0
  182. package/src/parser.js +439 -0
  183. package/src/policy/review-boundaries.js +165 -0
  184. package/src/project-config.js +535 -0
  185. package/src/proofs/backend-parity.js +19 -0
  186. package/src/proofs/contract-audit.js +220 -0
  187. package/src/proofs/ios-parity.js +7 -0
  188. package/src/proofs/issues-parity.js +10 -0
  189. package/src/proofs/web-parity.js +50 -0
  190. package/src/realization/api/build-api-realization.js +5 -0
  191. package/src/realization/api/index.js +1 -0
  192. package/src/realization/backend/build-backend-runtime-realization.js +82 -0
  193. package/src/realization/backend/index.d.ts +1 -0
  194. package/src/realization/backend/index.js +4 -0
  195. package/src/realization/db/build-db-realization.js +17 -0
  196. package/src/realization/db/index.js +3 -0
  197. package/src/realization/db/migration-plan.js +5 -0
  198. package/src/realization/db/snapshot.js +5 -0
  199. package/src/realization/ui/build-ui-shared-realization.js +305 -0
  200. package/src/realization/ui/build-web-realization.js +189 -0
  201. package/src/realization/ui/index.js +2 -0
  202. package/src/reconcile/docs.js +280 -0
  203. package/src/reconcile/index.js +3 -0
  204. package/src/reconcile/journeys.js +441 -0
  205. package/src/resolver/docs.js +1 -0
  206. package/src/resolver/enrich/acceptance-criterion.js +14 -0
  207. package/src/resolver/enrich/bug.js +12 -0
  208. package/src/resolver/enrich/component.js +2 -0
  209. package/src/resolver/enrich/index.js +1 -0
  210. package/src/resolver/enrich/pitch.js +18 -0
  211. package/src/resolver/enrich/requirement.js +20 -0
  212. package/src/resolver/enrich/task.js +16 -0
  213. package/src/resolver/expressions.js +1 -0
  214. package/src/resolver/index.js +2422 -0
  215. package/src/resolver/normalize.js +1 -0
  216. package/src/resolver.js +1 -0
  217. package/src/sdlc/adopt.js +65 -0
  218. package/src/sdlc/check.js +86 -0
  219. package/src/sdlc/dod/acceptance-criterion.js +22 -0
  220. package/src/sdlc/dod/bug.js +26 -0
  221. package/src/sdlc/dod/document.js +23 -0
  222. package/src/sdlc/dod/index.js +25 -0
  223. package/src/sdlc/dod/pitch.js +23 -0
  224. package/src/sdlc/dod/requirement.js +34 -0
  225. package/src/sdlc/dod/task.js +39 -0
  226. package/src/sdlc/explain.js +116 -0
  227. package/src/sdlc/history.js +80 -0
  228. package/src/sdlc/paths.js +11 -0
  229. package/src/sdlc/release.js +106 -0
  230. package/src/sdlc/scaffold.js +89 -0
  231. package/src/sdlc/status-filter.js +54 -0
  232. package/src/sdlc/transition.js +112 -0
  233. package/src/sdlc/transitions/acceptance-criterion.js +28 -0
  234. package/src/sdlc/transitions/bug.js +31 -0
  235. package/src/sdlc/transitions/document.js +29 -0
  236. package/src/sdlc/transitions/index.js +56 -0
  237. package/src/sdlc/transitions/pitch.js +34 -0
  238. package/src/sdlc/transitions/requirement.js +31 -0
  239. package/src/sdlc/transitions/task.js +34 -0
  240. package/src/template-trust.js +597 -0
  241. package/src/validator/expressions.js +1 -0
  242. package/src/validator/index.js +3424 -0
  243. package/src/validator/kinds.js +346 -0
  244. package/src/validator/per-kind/acceptance-criterion.js +91 -0
  245. package/src/validator/per-kind/bug.js +77 -0
  246. package/src/validator/per-kind/component.js +274 -0
  247. package/src/validator/per-kind/domain.js +205 -0
  248. package/src/validator/per-kind/pitch.js +101 -0
  249. package/src/validator/per-kind/requirement.js +75 -0
  250. package/src/validator/per-kind/task.js +96 -0
  251. package/src/validator/registry.js +1 -0
  252. package/src/validator/utils.js +12 -0
  253. package/src/validator.js +1 -0
  254. package/src/workflows.js +7597 -0
  255. package/src/workspace-docs.js +265 -0
  256. package/template-helpers/react.js +5 -0
  257. package/template-helpers/sveltekit.js +5 -0
@@ -0,0 +1,1196 @@
1
+ import { fieldSignature, symbolList } from "./shared.js";
2
+
3
+ const JSON_SCHEMA_DRAFT = "https://json-schema.org/draft/2020-12/schema";
4
+
5
+ function indexStatements(graph) {
6
+ const byId = new Map();
7
+ for (const statement of graph.statements) {
8
+ byId.set(statement.id, statement);
9
+ }
10
+ return byId;
11
+ }
12
+
13
+ function scalarSchema(typeName, byId) {
14
+ switch (typeName) {
15
+ case "string":
16
+ case "text":
17
+ return { type: "string" };
18
+ case "integer":
19
+ return { type: "integer" };
20
+ case "number":
21
+ return { type: "number" };
22
+ case "boolean":
23
+ return { type: "boolean" };
24
+ case "datetime":
25
+ return { type: "string", format: "date-time" };
26
+ case "uuid":
27
+ return { type: "string", format: "uuid" };
28
+ default: {
29
+ const target = byId.get(typeName);
30
+ if (target?.kind === "enum") {
31
+ return { type: "string", enum: target.values };
32
+ }
33
+ return { type: "string", "x-topogram-type": typeName };
34
+ }
35
+ }
36
+ }
37
+
38
+ function coerceDefaultValue(value, schema) {
39
+ if (value == null) {
40
+ return undefined;
41
+ }
42
+
43
+ if (schema.type === "integer") {
44
+ const parsed = Number.parseInt(value, 10);
45
+ return Number.isNaN(parsed) ? value : parsed;
46
+ }
47
+
48
+ if (schema.type === "number") {
49
+ const parsed = Number.parseFloat(value);
50
+ return Number.isNaN(parsed) ? value : parsed;
51
+ }
52
+
53
+ if (schema.type === "boolean") {
54
+ if (value === "true") return true;
55
+ if (value === "false") return false;
56
+ }
57
+
58
+ return value;
59
+ }
60
+
61
+ function schemaForField(field, byId) {
62
+ const schema = scalarSchema(field.fieldType, byId);
63
+ const defaultValue = coerceDefaultValue(field.defaultValue, schema);
64
+ return defaultValue === undefined ? schema : { ...schema, default: defaultValue };
65
+ }
66
+
67
+ function generateShapeJsonSchema(shape, byId) {
68
+ const fields = shape.projectedFields || shape.fields || [];
69
+ const properties = {};
70
+ const required = [];
71
+
72
+ for (const field of fields) {
73
+ properties[field.name] = schemaForField(field, byId);
74
+ if (field.requiredness === "required") {
75
+ required.push(field.name);
76
+ }
77
+ }
78
+
79
+ const schema = {
80
+ $schema: JSON_SCHEMA_DRAFT,
81
+ $id: `topogram:shape:${shape.id}`,
82
+ title: shape.name || shape.id,
83
+ description: shape.description || undefined,
84
+ type: "object",
85
+ properties,
86
+ additionalProperties: false
87
+ };
88
+
89
+ if (required.length > 0) {
90
+ schema.required = required;
91
+ }
92
+
93
+ return schema;
94
+ }
95
+
96
+ function getCapability(graph, capabilityId) {
97
+ const byId = indexStatements(graph);
98
+ const capability = byId.get(capabilityId);
99
+ if (!capability || capability.kind !== "capability") {
100
+ throw new Error(`No capability found with id '${capabilityId}'`);
101
+ }
102
+ return capability;
103
+ }
104
+
105
+ function routeSegmentFromCapabilityId(id) {
106
+ return id.replace(/^cap_/, "").replace(/_/g, "-");
107
+ }
108
+
109
+ function methodFromCapability(capability) {
110
+ if (capability.creates.length > 0) return "POST";
111
+ if (capability.updates.length > 0) return "PATCH";
112
+ if (capability.deletes.length > 0) return "DELETE";
113
+ return "GET";
114
+ }
115
+
116
+ function pathFromCapability(capability) {
117
+ const resourceRef =
118
+ capability.creates[0]?.target ||
119
+ capability.updates[0]?.target ||
120
+ capability.deletes[0]?.target ||
121
+ capability.reads[0]?.target;
122
+
123
+ const resourceSegment = resourceRef?.id ? resourceRef.id.replace(/^entity_/, "").replace(/_/g, "-") : "resource";
124
+ const opSegment = routeSegmentFromCapabilityId(capability.id);
125
+
126
+ if (capability.creates.length > 0) {
127
+ return `/${resourceSegment}`;
128
+ }
129
+ if (capability.updates.length > 0 || capability.deletes.length > 0) {
130
+ return `/${resourceSegment}/{id}`;
131
+ }
132
+ if (capability.id.startsWith("cap_list_")) {
133
+ return `/${resourceSegment}`;
134
+ }
135
+ return `/${resourceSegment}/${opSegment}`;
136
+ }
137
+
138
+ function normalizeResponseMetadata(responseEntry) {
139
+ return {
140
+ mode: responseEntry?.mode || null,
141
+ itemShapeId: responseEntry?.item?.id || null,
142
+ ordering: responseEntry?.sort
143
+ ? {
144
+ field: responseEntry.sort.field,
145
+ direction: responseEntry.sort.direction
146
+ }
147
+ : null,
148
+ cursor: responseEntry?.cursor
149
+ ? {
150
+ requestAfter: responseEntry.cursor.requestAfter,
151
+ responseNext: responseEntry.cursor.responseNext,
152
+ responsePrev: responseEntry.cursor.responsePrev || null
153
+ }
154
+ : null,
155
+ limit: responseEntry?.limit
156
+ ? {
157
+ field: responseEntry.limit.field,
158
+ defaultValue: responseEntry.limit.defaultValue,
159
+ maxValue: responseEntry.limit.maxValue
160
+ }
161
+ : null,
162
+ total: responseEntry?.total
163
+ ? {
164
+ included: responseEntry.total.included
165
+ }
166
+ : null
167
+ };
168
+ }
169
+
170
+ function apiMetadataForCapability(graph, capability) {
171
+ const projections = graph.byKind.projection || [];
172
+
173
+ for (const projection of projections) {
174
+ const httpEntry = (projection.http || []).find((entry) => entry.capability?.id === capability.id);
175
+ if (!httpEntry) continue;
176
+
177
+ const responseEntry = (projection.httpResponses || []).find((entry) => entry.capability?.id === capability.id);
178
+ return {
179
+ projection: {
180
+ id: projection.id,
181
+ name: projection.name || projection.id
182
+ },
183
+ method: httpEntry.method || methodFromCapability(capability),
184
+ path: httpEntry.path || pathFromCapability(capability),
185
+ success: httpEntry.success || (capability.creates.length > 0 ? 201 : 200),
186
+ auth: httpEntry.auth || "none",
187
+ request: httpEntry.request || (capability.input.length > 0 ? "body" : "none"),
188
+ errorMappings: (projection.httpErrors || [])
189
+ .filter((entry) => entry.capability?.id === capability.id)
190
+ .map((entry) => ({
191
+ code: entry.code,
192
+ status: entry.status
193
+ })),
194
+ fieldBindings: (projection.httpFields || []).filter((entry) => entry.capability?.id === capability.id),
195
+ preconditions: (projection.httpPreconditions || [])
196
+ .filter((entry) => entry.capability?.id === capability.id)
197
+ .map((entry) => ({
198
+ header: entry.header,
199
+ required: entry.required,
200
+ error: entry.error,
201
+ source: entry.source,
202
+ code: entry.code
203
+ })),
204
+ idempotency: (projection.httpIdempotency || [])
205
+ .filter((entry) => entry.capability?.id === capability.id)
206
+ .map((entry) => ({
207
+ header: entry.header,
208
+ required: entry.required,
209
+ error: entry.error,
210
+ code: entry.code
211
+ })),
212
+ cache: (projection.httpCache || [])
213
+ .filter((entry) => entry.capability?.id === capability.id)
214
+ .map((entry) => ({
215
+ responseHeader: entry.responseHeader,
216
+ requestHeader: entry.requestHeader,
217
+ required: entry.required,
218
+ notModified: entry.notModified,
219
+ source: entry.source,
220
+ code: entry.code
221
+ })),
222
+ delete: (projection.httpDelete || [])
223
+ .filter((entry) => entry.capability?.id === capability.id)
224
+ .map((entry) => ({
225
+ mode: entry.mode,
226
+ field: entry.field,
227
+ value: entry.value,
228
+ response: entry.response
229
+ })),
230
+ async: (projection.httpAsync || [])
231
+ .filter((entry) => entry.capability?.id === capability.id)
232
+ .map((entry) => ({
233
+ mode: entry.mode,
234
+ accepted: entry.accepted,
235
+ locationHeader: entry.locationHeader,
236
+ retryAfterHeader: entry.retryAfterHeader,
237
+ statusPath: entry.statusPath,
238
+ statusCapability: entry.statusCapability,
239
+ job: entry.job
240
+ })),
241
+ status: (projection.httpStatus || [])
242
+ .filter((entry) => entry.capability?.id === capability.id)
243
+ .map((entry) => ({
244
+ asyncFor: entry.asyncFor,
245
+ stateField: entry.stateField,
246
+ completed: entry.completed,
247
+ failed: entry.failed,
248
+ expired: entry.expired,
249
+ downloadCapability: entry.downloadCapability,
250
+ downloadField: entry.downloadField,
251
+ errorField: entry.errorField
252
+ })),
253
+ download: (projection.httpDownload || [])
254
+ .filter((entry) => entry.capability?.id === capability.id)
255
+ .map((entry) => ({
256
+ asyncFor: entry.asyncFor,
257
+ media: entry.media,
258
+ filename: entry.filename,
259
+ disposition: entry.disposition
260
+ })),
261
+ authz: (projection.httpAuthz || [])
262
+ .filter((entry) => entry.capability?.id === capability.id)
263
+ .map((entry) => ({
264
+ role: entry.role,
265
+ permission: entry.permission,
266
+ claim: entry.claim,
267
+ claimValue: entry.claimValue,
268
+ ownership: entry.ownership,
269
+ ownershipField: entry.ownershipField
270
+ })),
271
+ callbacks: (projection.httpCallbacks || [])
272
+ .filter((entry) => entry.capability?.id === capability.id)
273
+ .map((entry) => ({
274
+ event: entry.event,
275
+ targetField: entry.targetField,
276
+ method: entry.method,
277
+ payload: entry.payload,
278
+ success: entry.success
279
+ })),
280
+ response: normalizeResponseMetadata(responseEntry)
281
+ };
282
+ }
283
+
284
+ return {
285
+ projection: null,
286
+ method: methodFromCapability(capability),
287
+ path: pathFromCapability(capability),
288
+ success: capability.creates.length > 0 ? 201 : 200,
289
+ auth: "none",
290
+ request: capability.input.length > 0 ? "body" : "none",
291
+ errorMappings: [],
292
+ fieldBindings: [],
293
+ preconditions: [],
294
+ idempotency: [],
295
+ cache: [],
296
+ delete: [],
297
+ async: [],
298
+ status: [],
299
+ download: [],
300
+ authz: [],
301
+ callbacks: [],
302
+ response: normalizeResponseMetadata(null)
303
+ };
304
+ }
305
+
306
+ function fieldTransportBindings(contract, apiMetadata, direction) {
307
+ const bindings = (apiMetadata.fieldBindings || []).filter((binding) => binding.direction === direction);
308
+ const byField = new Map(bindings.map((binding) => [binding.field, binding]));
309
+ const inferredBindings = new Map();
310
+
311
+ if (direction === "input" && apiMetadata.response?.cursor?.requestAfter) {
312
+ inferredBindings.set(apiMetadata.response.cursor.requestAfter, { location: "query", wireName: "after" });
313
+ }
314
+ if (direction === "input" && apiMetadata.response?.limit?.field) {
315
+ inferredBindings.set(apiMetadata.response.limit.field, { location: "query", wireName: "limit" });
316
+ }
317
+
318
+ return contract.fields.map((field) => {
319
+ const binding = byField.get(field.name) || byField.get(field.sourceName) || inferredBindings.get(field.name);
320
+ return {
321
+ ...field,
322
+ transport: {
323
+ location: binding?.location || (direction === "input" ? apiMetadata.request : "body"),
324
+ wireName: binding?.wireName || field.name
325
+ }
326
+ };
327
+ });
328
+ }
329
+
330
+ function splitFieldsByLocation(fields) {
331
+ return {
332
+ path: fields.filter((field) => field.transport.location === "path"),
333
+ query: fields.filter((field) => field.transport.location === "query"),
334
+ header: fields.filter((field) => field.transport.location === "header"),
335
+ body: fields.filter((field) => field.transport.location === "body")
336
+ };
337
+ }
338
+
339
+ function fieldContract(field, byId) {
340
+ return {
341
+ name: field.name,
342
+ sourceName: field.sourceName ?? field.name,
343
+ required: field.requiredness === "required",
344
+ schema: schemaForField(field, byId)
345
+ };
346
+ }
347
+
348
+ function contractFromShape(shape, byId, direction) {
349
+ const fields = (shape.projectedFields || shape.fields || []).map((field) => fieldContract(field, byId));
350
+ return {
351
+ type: direction === "request" ? "api_request_contract" : "api_response_contract",
352
+ shape: {
353
+ id: shape.id,
354
+ name: shape.name || shape.id
355
+ },
356
+ fields,
357
+ required: fields.filter((field) => field.required).map((field) => field.name),
358
+ jsonSchema: generateShapeJsonSchema(shape, byId)
359
+ };
360
+ }
361
+
362
+ function isCollectionCapability(capability, apiMetadata) {
363
+ if (["collection", "paged", "cursor"].includes(apiMetadata?.response?.mode)) return true;
364
+ if (apiMetadata?.response?.mode === "item") return false;
365
+ if ((apiMetadata?.method || "").toUpperCase() !== "GET") return false;
366
+ return capability.id.startsWith("cap_list_");
367
+ }
368
+
369
+ function policyConstraintsForCapability(graph, capability) {
370
+ const rules = graph.byKind.rule || [];
371
+ return rules
372
+ .filter((rule) => rule.appliesTo.some((target) => target.id === capability.id))
373
+ .map((rule) => ({
374
+ type: "api_policy_constraint",
375
+ rule: {
376
+ id: rule.id,
377
+ name: rule.name || rule.id
378
+ },
379
+ requirement: rule.requirementNode || null,
380
+ condition: rule.conditionNode || null,
381
+ severity: rule.severity
382
+ }));
383
+ }
384
+
385
+ function apiErrorCasesForCapability(capability, policyConstraints, apiMetadata) {
386
+ const errors = [];
387
+ const overrideMap = new Map((apiMetadata.errorMappings || []).map((mapping) => [mapping.code, mapping.status]));
388
+
389
+ for (const policy of policyConstraints) {
390
+ errors.push({
391
+ type: "api_error_case",
392
+ code: policy.rule.id,
393
+ status: overrideMap.get(policy.rule.id) || (policy.severity === "error" ? 400 : 422),
394
+ source: "policy"
395
+ });
396
+ }
397
+
398
+ if (capability.input.length > 0) {
399
+ errors.push({
400
+ type: "api_error_case",
401
+ code: `${capability.id}_invalid_request`,
402
+ status: overrideMap.get(`${capability.id}_invalid_request`) || 400,
403
+ source: "request_contract"
404
+ });
405
+ }
406
+
407
+ if (apiMetadata.response?.mode === "cursor") {
408
+ errors.push({
409
+ type: "api_error_case",
410
+ code: `${capability.id}_invalid_cursor`,
411
+ status: overrideMap.get(`${capability.id}_invalid_cursor`) || 400,
412
+ source: "cursor_contract"
413
+ });
414
+ errors.push({
415
+ type: "api_error_case",
416
+ code: `${capability.id}_invalid_limit`,
417
+ status: overrideMap.get(`${capability.id}_invalid_limit`) || 400,
418
+ source: "cursor_contract"
419
+ });
420
+ }
421
+
422
+ for (const precondition of apiMetadata.preconditions || []) {
423
+ errors.push({
424
+ type: "api_error_case",
425
+ code: precondition.code,
426
+ status: precondition.error || 412,
427
+ source: "precondition"
428
+ });
429
+ }
430
+
431
+ for (const idempotency of apiMetadata.idempotency || []) {
432
+ errors.push({
433
+ type: "api_error_case",
434
+ code: idempotency.code,
435
+ status: idempotency.error || 409,
436
+ source: "idempotency"
437
+ });
438
+ }
439
+
440
+ const seenCodes = new Set(errors.map((error) => error.code));
441
+ for (const mapping of apiMetadata.errorMappings || []) {
442
+ if (seenCodes.has(mapping.code)) continue;
443
+ errors.push({
444
+ type: "api_error_case",
445
+ code: mapping.code,
446
+ status: mapping.status,
447
+ source: "projection_mapping"
448
+ });
449
+ }
450
+
451
+ return errors;
452
+ }
453
+
454
+ function cloneSchema(value) {
455
+ return JSON.parse(JSON.stringify(value));
456
+ }
457
+
458
+ function responseContractForCapability(shape, byId, capability, apiMetadata) {
459
+ const baseContract = contractFromShape(shape, byId, "response");
460
+ const responseMode = apiMetadata?.response?.mode || (isCollectionCapability(capability, apiMetadata) ? "collection" : "item");
461
+
462
+ if (responseMode === "item") {
463
+ return { ...baseContract, mode: "item", collection: false, itemJsonSchema: null, pagination: null };
464
+ }
465
+
466
+ if (responseMode === "paged") {
467
+ return {
468
+ ...baseContract,
469
+ mode: "paged",
470
+ collection: true,
471
+ itemJsonSchema: baseContract.jsonSchema,
472
+ pagination: {
473
+ itemsProperty: "items",
474
+ pageProperty: "page",
475
+ pageSizeProperty: "page_size",
476
+ totalProperty: "total"
477
+ },
478
+ jsonSchema: {
479
+ type: "object",
480
+ additionalProperties: false,
481
+ required: ["items", "page", "page_size", "total"],
482
+ properties: {
483
+ items: { type: "array", items: cloneSchema(baseContract.jsonSchema) },
484
+ page: { type: "integer" },
485
+ page_size: { type: "integer" },
486
+ total: { type: "integer" }
487
+ }
488
+ }
489
+ };
490
+ }
491
+
492
+ if (responseMode === "cursor") {
493
+ const totalIncluded = apiMetadata.response.total?.included === true;
494
+ return {
495
+ ...baseContract,
496
+ mode: "cursor",
497
+ collection: true,
498
+ itemJsonSchema: baseContract.jsonSchema,
499
+ pagination: null,
500
+ itemShape: { id: shape.id, name: shape.name || shape.id },
501
+ ordering: apiMetadata.response.ordering,
502
+ cursor: apiMetadata.response.cursor,
503
+ limit: apiMetadata.response.limit,
504
+ total: apiMetadata.response.total,
505
+ jsonSchema: {
506
+ type: "object",
507
+ additionalProperties: false,
508
+ required: ["items", apiMetadata.response.cursor?.responseNext || "next_cursor"],
509
+ properties: {
510
+ items: { type: "array", items: cloneSchema(baseContract.jsonSchema) },
511
+ [apiMetadata.response.cursor?.responseNext || "next_cursor"]: { type: "string" },
512
+ ...(apiMetadata.response.cursor?.responsePrev
513
+ ? { [apiMetadata.response.cursor.responsePrev]: { type: "string" } }
514
+ : {}),
515
+ ...(totalIncluded ? { total: { type: "integer" } } : {})
516
+ }
517
+ }
518
+ };
519
+ }
520
+
521
+ return {
522
+ ...baseContract,
523
+ mode: "collection",
524
+ collection: true,
525
+ itemJsonSchema: baseContract.jsonSchema,
526
+ pagination: null,
527
+ jsonSchema: { type: "array", items: cloneSchema(baseContract.jsonSchema) }
528
+ };
529
+ }
530
+
531
+ function buildApiContractForCapability(graph, capability, byId) {
532
+ const inputShape = capability.input[0]?.id ? byId.get(capability.input[0].id) : null;
533
+ const policyConstraints = policyConstraintsForCapability(graph, capability);
534
+ const apiMetadata = apiMetadataForCapability(graph, capability);
535
+ const outputShapeId = apiMetadata.response.itemShapeId || capability.output[0]?.id || null;
536
+ const outputShape = outputShapeId ? byId.get(outputShapeId) : null;
537
+ const requestContract = inputShape ? contractFromShape(inputShape, byId, "request") : null;
538
+ const responseContract = outputShape ? responseContractForCapability(outputShape, byId, capability, apiMetadata) : null;
539
+ const requestFields = requestContract ? fieldTransportBindings(requestContract, apiMetadata, "input") : [];
540
+ const responseFields = responseContract ? fieldTransportBindings(responseContract, apiMetadata, "output") : [];
541
+
542
+ return {
543
+ type: "api_contract_graph",
544
+ capability: {
545
+ id: capability.id,
546
+ name: capability.name || capability.id,
547
+ description: capability.description || null
548
+ },
549
+ endpoint: {
550
+ type: "api_endpoint",
551
+ operationId: capability.id,
552
+ method: apiMetadata.method,
553
+ path: apiMetadata.path,
554
+ successStatus: apiMetadata.success,
555
+ auth: apiMetadata.auth,
556
+ requestPlacement: apiMetadata.request,
557
+ projection: apiMetadata.projection,
558
+ preconditions: apiMetadata.preconditions || [],
559
+ idempotency: apiMetadata.idempotency || [],
560
+ cache: apiMetadata.cache || [],
561
+ delete: apiMetadata.delete || [],
562
+ async: apiMetadata.async || [],
563
+ status: apiMetadata.status || [],
564
+ download: apiMetadata.download || [],
565
+ authz: apiMetadata.authz || [],
566
+ callbacks: apiMetadata.callbacks || [],
567
+ actors: capability.actors.map((actor, index) => ({
568
+ type: "api_actor",
569
+ id: actor.id,
570
+ kind: actor.target?.kind || null,
571
+ order: index
572
+ }))
573
+ },
574
+ requestContract: requestContract
575
+ ? { ...requestContract, fields: requestFields, transport: splitFieldsByLocation(requestFields) }
576
+ : null,
577
+ responseContract: responseContract
578
+ ? { ...responseContract, fields: responseFields, transport: splitFieldsByLocation(responseFields) }
579
+ : null,
580
+ policy: policyConstraints,
581
+ errors: apiErrorCasesForCapability(capability, policyConstraints, apiMetadata)
582
+ };
583
+ }
584
+
585
+ export function generateApiContractGraph(graph, options = {}) {
586
+ const byId = indexStatements(graph);
587
+ const capabilities = graph.byKind.capability || [];
588
+
589
+ if (options.capabilityId) {
590
+ return buildApiContractForCapability(graph, getCapability(graph, options.capabilityId), byId);
591
+ }
592
+
593
+ const output = {};
594
+ for (const capability of capabilities) {
595
+ output[capability.id] = buildApiContractForCapability(graph, capability, byId);
596
+ }
597
+ return output;
598
+ }
599
+
600
+ export function generateApiContractDebug(graph, options = {}) {
601
+ const capabilities = options.capabilityId ? [getCapability(graph, options.capabilityId)] : graph.byKind.capability || [];
602
+ const byId = indexStatements(graph);
603
+ const lines = [];
604
+
605
+ lines.push("# API Contract Debug");
606
+ lines.push("");
607
+ lines.push(`Generated from \`${graph.root}\``);
608
+ lines.push("");
609
+
610
+ for (const capability of capabilities) {
611
+ const contract = buildApiContractForCapability(graph, capability, byId);
612
+ lines.push(`## \`${capability.id}\` - ${capability.name || capability.id}`);
613
+ lines.push("");
614
+ if (capability.description) {
615
+ lines.push(capability.description);
616
+ lines.push("");
617
+ }
618
+ lines.push(`Endpoint: \`${contract.endpoint.method} ${contract.endpoint.path}\``);
619
+ lines.push(`Success: \`${contract.endpoint.successStatus}\``);
620
+ lines.push(`Auth: \`${contract.endpoint.auth}\``);
621
+ lines.push(`Request placement: \`${contract.endpoint.requestPlacement}\``);
622
+ if (contract.endpoint.projection?.id) {
623
+ lines.push(`Projection: \`${contract.endpoint.projection.id}\``);
624
+ }
625
+ if (contract.endpoint.preconditions?.length > 0) {
626
+ lines.push(`Preconditions: ${contract.endpoint.preconditions.map((precondition) => `\`${precondition.header}\``).join(", ")}`);
627
+ }
628
+ if (contract.endpoint.idempotency?.length > 0) {
629
+ lines.push(`Idempotency: ${contract.endpoint.idempotency.map((rule) => `\`${rule.header}\``).join(", ")}`);
630
+ }
631
+ if (contract.endpoint.cache?.length > 0) {
632
+ lines.push(`Cache: ${contract.endpoint.cache.map((rule) => `\`${rule.responseHeader}\` via \`${rule.requestHeader}\` -> ${rule.notModified}`).join(", ")}`);
633
+ }
634
+ if (contract.endpoint.delete?.length > 0) {
635
+ lines.push(`Delete: ${contract.endpoint.delete.map((rule) => `\`${rule.mode}\`${rule.field ? ` via \`${rule.field}=${rule.value}\`` : ""} response \`${rule.response}\``).join(", ")}`);
636
+ }
637
+ if (contract.endpoint.async?.length > 0) {
638
+ lines.push(`Async: ${contract.endpoint.async.map((rule) => `\`${rule.mode}\` accepted ${rule.accepted} status \`${rule.statusPath}\`${rule.statusCapability?.id ? ` via \`${rule.statusCapability.id}\`` : ""}`).join(", ")}`);
639
+ }
640
+ if (contract.endpoint.status?.length > 0) {
641
+ lines.push(`Status: ${contract.endpoint.status.map((rule) => `state \`${rule.stateField}\` complete \`${rule.completed}\` fail \`${rule.failed}\`${rule.downloadCapability?.id ? ` download \`${rule.downloadCapability.id}\`` : ""}`).join(", ")}`);
642
+ }
643
+ if (contract.endpoint.download?.length > 0) {
644
+ lines.push(`Download: ${contract.endpoint.download.map((rule) => `\`${rule.media}\` ${rule.disposition}${rule.filename ? ` filename \`${rule.filename}\`` : ""}`).join(", ")}`);
645
+ }
646
+ if (contract.endpoint.authz?.length > 0) {
647
+ lines.push(`Authorization: ${contract.endpoint.authz.map((rule) => [
648
+ rule.role ? `role \`${rule.role}\`` : null,
649
+ rule.permission ? `permission \`${rule.permission}\`` : null,
650
+ rule.claim ? `claim \`${rule.claim}\`${rule.claimValue ? ` = \`${rule.claimValue}\`` : ""}` : null,
651
+ rule.ownership ? `ownership \`${rule.ownership}\`` : null
652
+ ].filter(Boolean).join(", ")).join(" | ")}`);
653
+ }
654
+ if (contract.endpoint.callbacks?.length > 0) {
655
+ lines.push(`Callbacks: ${contract.endpoint.callbacks.map((rule) => `\`${rule.event}\` -> \`${rule.method}\` via \`${rule.targetField}\``).join(", ")}`);
656
+ }
657
+ lines.push(`Actors: ${symbolList(capability.actors)}`);
658
+ lines.push("");
659
+
660
+ lines.push("Request contract:");
661
+ if (!contract.requestContract) {
662
+ lines.push("- _none_");
663
+ } else {
664
+ lines.push(`- shape: \`${contract.requestContract.shape.id}\``);
665
+ for (const field of contract.requestContract.fields) {
666
+ lines.push(`- ${fieldSignature({
667
+ name: field.transport?.wireName || field.name,
668
+ sourceName: field.sourceName,
669
+ fieldType: field.schema["x-topogram-type"] || field.schema.type || "unknown",
670
+ requiredness: field.required ? "required" : "optional",
671
+ defaultValue: field.schema.default ?? null
672
+ })} in \`${field.transport?.location}\``);
673
+ }
674
+ }
675
+ lines.push("");
676
+
677
+ lines.push("Response contract:");
678
+ if (!contract.responseContract) {
679
+ lines.push("- _none_");
680
+ } else {
681
+ lines.push(`- shape: \`${contract.responseContract.shape.id}\``);
682
+ lines.push(`- mode: \`${contract.responseContract.mode}\``);
683
+ if (contract.responseContract.pagination) {
684
+ lines.push(`- envelope: \`${contract.responseContract.pagination.itemsProperty}\`, \`${contract.responseContract.pagination.pageProperty}\`, \`${contract.responseContract.pagination.pageSizeProperty}\`, \`${contract.responseContract.pagination.totalProperty}\``);
685
+ }
686
+ if (contract.responseContract.cursor) {
687
+ lines.push(`- cursor: request \`${contract.responseContract.cursor.requestAfter}\`, next \`${contract.responseContract.cursor.responseNext}\`${contract.responseContract.cursor.responsePrev ? `, prev \`${contract.responseContract.cursor.responsePrev}\`` : ""}`);
688
+ }
689
+ if (contract.responseContract.limit) {
690
+ lines.push(`- limit: field \`${contract.responseContract.limit.field}\`, default ${contract.responseContract.limit.defaultValue}, max ${contract.responseContract.limit.maxValue}`);
691
+ }
692
+ if (contract.responseContract.ordering) {
693
+ lines.push(`- sort: \`${contract.responseContract.ordering.field} ${contract.responseContract.ordering.direction}\``);
694
+ }
695
+ if (contract.responseContract.total) {
696
+ lines.push(`- total included: \`${contract.responseContract.total.included}\``);
697
+ }
698
+ for (const field of contract.responseContract.fields) {
699
+ lines.push(`- ${fieldSignature({
700
+ name: field.transport?.wireName || field.name,
701
+ sourceName: field.sourceName,
702
+ fieldType: field.schema["x-topogram-type"] || field.schema.type || "unknown",
703
+ requiredness: field.required ? "required" : "optional",
704
+ defaultValue: field.schema.default ?? null
705
+ })} in \`${field.transport?.location}\``);
706
+ }
707
+ }
708
+ lines.push("");
709
+
710
+ lines.push("Policy constraints:");
711
+ if (contract.policy.length === 0) {
712
+ lines.push("- _none_");
713
+ } else {
714
+ for (const policy of contract.policy) {
715
+ lines.push(`- \`${policy.rule.id}\` (${policy.severity})`);
716
+ }
717
+ }
718
+ lines.push("");
719
+
720
+ lines.push("Error cases:");
721
+ for (const error of contract.errors) {
722
+ lines.push(`- \`${error.code}\` -> ${error.status}`);
723
+ }
724
+ lines.push("");
725
+ }
726
+
727
+ return `${lines.join("\n").trimEnd()}\n`;
728
+ }
729
+
730
+ function componentNameFromShape(shapeId, suffix) {
731
+ const base = shapeId.split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
732
+ return `${base}${suffix}`;
733
+ }
734
+
735
+ function componentNameFromErrorCode(code, suffix = "Error") {
736
+ const base = code
737
+ .split(/[^A-Za-z0-9]+/)
738
+ .filter(Boolean)
739
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
740
+ .join("");
741
+ return `${base}${suffix}`;
742
+ }
743
+
744
+ function refSchema(name) {
745
+ return { $ref: `#/components/schemas/${name}` };
746
+ }
747
+
748
+ function openApiParameter(field, location) {
749
+ return {
750
+ name: field.transport?.wireName || field.name,
751
+ in: location,
752
+ required: location === "path" ? true : field.required,
753
+ schema: cloneSchema(field.schema)
754
+ };
755
+ }
756
+
757
+ function requestBodySchema(contract) {
758
+ const requestFields = contract.requestContract?.transport?.body || [];
759
+ const schema = { type: "object", properties: {}, additionalProperties: false };
760
+ const required = [];
761
+
762
+ for (const field of requestFields) {
763
+ schema.properties[field.transport?.wireName || field.name] = cloneSchema(field.schema);
764
+ if (field.required) {
765
+ required.push(field.transport?.wireName || field.name);
766
+ }
767
+ }
768
+
769
+ if (required.length > 0) {
770
+ schema.required = required;
771
+ }
772
+ return schema;
773
+ }
774
+
775
+ function responseSchemaForContract(contract, componentNames) {
776
+ if (!contract.responseContract) return null;
777
+
778
+ if (contract.responseContract.mode === "paged" || contract.responseContract.mode === "cursor") {
779
+ const envelopeSchemaName = componentNames.responseEnvelope.get(contract.capability.id);
780
+ if (envelopeSchemaName) return refSchema(envelopeSchemaName);
781
+ }
782
+
783
+ const responseSchemaName = componentNames.response.get(contract.responseContract.shape.id);
784
+ if (!responseSchemaName) return cloneSchema(contract.responseContract.jsonSchema);
785
+
786
+ if (contract.responseContract.collection) {
787
+ return { type: "array", items: refSchema(responseSchemaName) };
788
+ }
789
+
790
+ return refSchema(responseSchemaName);
791
+ }
792
+
793
+ function successHeadersForContract(contract) {
794
+ const headerEntries = [
795
+ ...(contract.endpoint.cache || []).map((rule) => [rule.responseHeader, { schema: { type: "string" } }]),
796
+ ...(contract.endpoint.async || []).flatMap((rule) => [
797
+ [rule.locationHeader, { schema: { type: "string" } }],
798
+ [rule.retryAfterHeader, { schema: { type: "integer" } }]
799
+ ]),
800
+ ...(contract.endpoint.download || []).flatMap((rule) =>
801
+ rule.disposition ? [["Content-Disposition", { schema: { type: "string" } }]] : []
802
+ )
803
+ ].filter(([name]) => Boolean(name));
804
+
805
+ return headerEntries.length > 0 ? Object.fromEntries(headerEntries) : undefined;
806
+ }
807
+
808
+ function asyncLinksForContract(contract) {
809
+ const links = {};
810
+ for (const rule of contract.endpoint.async || []) {
811
+ if (!rule.statusCapability?.id) continue;
812
+ links[`${rule.statusCapability.id}Status`] = {
813
+ operationId: rule.statusCapability.id,
814
+ parameters: { job_id: "$response.body#/job_id" },
815
+ description: `Follow ${rule.statusCapability.id} to monitor the async job`
816
+ };
817
+ }
818
+ return Object.keys(links).length > 0 ? links : undefined;
819
+ }
820
+
821
+ function statusLinksForContract(contract) {
822
+ const links = {};
823
+ for (const rule of contract.endpoint.status || []) {
824
+ if (!rule.downloadCapability?.id) continue;
825
+ links[`${rule.downloadCapability.id}Download`] = {
826
+ operationId: rule.downloadCapability.id,
827
+ parameters: { job_id: "$response.body#/job_id" },
828
+ description: `Use ${rule.downloadCapability.id} when the job is complete`
829
+ };
830
+ }
831
+ return Object.keys(links).length > 0 ? links : undefined;
832
+ }
833
+
834
+ function authorizationExtension(contract) {
835
+ if (!contract.endpoint.authz || contract.endpoint.authz.length === 0) return undefined;
836
+ return contract.endpoint.authz.map((rule) => ({
837
+ ...(rule.role ? { role: rule.role } : {}),
838
+ ...(rule.permission ? { permission: rule.permission } : {}),
839
+ ...(rule.claim ? { claim: rule.claim } : {}),
840
+ ...(rule.claimValue ? { claimValue: rule.claimValue } : {}),
841
+ ...(rule.ownership ? { ownership: rule.ownership } : {}),
842
+ ...(rule.ownershipField ? { ownershipField: rule.ownershipField } : {})
843
+ }));
844
+ }
845
+
846
+ function callbackExpressionForField(contract, callback) {
847
+ const field = contract.requestContract?.fields?.find(
848
+ (item) => item.name === callback.targetField || item.sourceName === callback.targetField
849
+ );
850
+ const location = field?.transport?.location || contract.endpoint.requestPlacement;
851
+ const wireName = field?.transport?.wireName || callback.targetField;
852
+
853
+ if (location === "query") return `{$request.query.${wireName}}`;
854
+ if (location === "header") return `{$request.header.${wireName}}`;
855
+ return `{$request.body#/${wireName}}`;
856
+ }
857
+
858
+ function callbackRequestBodySchema(callback, componentNames) {
859
+ const payloadShapeId = callback.payload?.id || null;
860
+ if (!payloadShapeId) return { type: "object" };
861
+ const schemaName = componentNames.response.get(payloadShapeId);
862
+ return schemaName ? refSchema(schemaName) : { type: "object" };
863
+ }
864
+
865
+ function callbacksObjectForContract(contract, componentNames) {
866
+ if (!contract.endpoint.callbacks || contract.endpoint.callbacks.length === 0) return undefined;
867
+ const callbacks = {};
868
+ for (const callback of contract.endpoint.callbacks) {
869
+ callbacks[callback.event] = {
870
+ [callbackExpressionForField(contract, callback)]: {
871
+ [callback.method.toLowerCase()]: {
872
+ requestBody: {
873
+ required: true,
874
+ content: {
875
+ "application/json": {
876
+ schema: callbackRequestBodySchema(callback, componentNames)
877
+ }
878
+ }
879
+ },
880
+ responses: {
881
+ [String(callback.success || 202)]: { description: "Callback accepted" }
882
+ }
883
+ }
884
+ }
885
+ };
886
+ }
887
+ return callbacks;
888
+ }
889
+
890
+ function operationFromContract(contract, componentNames) {
891
+ const operation = {
892
+ operationId: contract.endpoint.operationId,
893
+ summary: contract.capability.name,
894
+ responses: {}
895
+ };
896
+
897
+ if (contract.capability.description) {
898
+ operation.description = contract.capability.description;
899
+ }
900
+
901
+ const authzExtension = authorizationExtension(contract);
902
+ if (authzExtension) {
903
+ operation["x-topogram-authorization"] = authzExtension;
904
+ }
905
+
906
+ if (contract.endpoint.auth && contract.endpoint.auth !== "none") {
907
+ operation.security = [{ bearerAuth: [] }];
908
+ }
909
+
910
+ const requestFields = contract.requestContract?.transport || { path: [], query: [], header: [], body: [] };
911
+ const parameters = [
912
+ ...requestFields.path.map((field) => openApiParameter(field, "path")),
913
+ ...requestFields.query.map((field) => openApiParameter(field, "query")),
914
+ ...requestFields.header.map((field) => openApiParameter(field, "header")),
915
+ ...(contract.endpoint.preconditions || []).map((precondition) => ({
916
+ name: precondition.header,
917
+ in: "header",
918
+ required: precondition.required,
919
+ schema: { type: "string" }
920
+ })),
921
+ ...(contract.endpoint.idempotency || []).map((rule) => ({
922
+ name: rule.header,
923
+ in: "header",
924
+ required: rule.required,
925
+ schema: { type: "string" }
926
+ })),
927
+ ...(contract.endpoint.cache || []).map((rule) => ({
928
+ name: rule.requestHeader,
929
+ in: "header",
930
+ required: rule.required,
931
+ schema: { type: "string" }
932
+ }))
933
+ ];
934
+ if (parameters.length > 0) operation.parameters = parameters;
935
+
936
+ const callbacks = callbacksObjectForContract(contract, componentNames);
937
+ if (callbacks) {
938
+ operation.callbacks = callbacks;
939
+ }
940
+
941
+ if (requestFields.body.length > 0 && contract.requestContract) {
942
+ const requestSchemaName = componentNames.request.get(contract.capability.id);
943
+ operation.requestBody = {
944
+ required: requestFields.body.some((field) => field.required),
945
+ content: {
946
+ "application/json": {
947
+ schema: requestSchemaName ? refSchema(requestSchemaName) : { type: "object" }
948
+ }
949
+ }
950
+ };
951
+ }
952
+
953
+ if (contract.endpoint.download?.length > 0) {
954
+ const rule = contract.endpoint.download[0];
955
+ operation.responses[String(contract.endpoint.successStatus)] = {
956
+ description: "Success",
957
+ headers: successHeadersForContract(contract),
958
+ content: {
959
+ [rule.media || "application/octet-stream"]: {
960
+ schema: { type: "string", format: "binary" }
961
+ }
962
+ }
963
+ };
964
+ } else if (contract.responseContract) {
965
+ operation.responses[String(contract.endpoint.successStatus)] = {
966
+ description: contract.endpoint.async?.length > 0 ? "Accepted" : "Success",
967
+ headers: successHeadersForContract(contract),
968
+ content: {
969
+ "application/json": {
970
+ schema: responseSchemaForContract(contract, componentNames) || { type: "object" }
971
+ }
972
+ },
973
+ ...(contract.endpoint.async?.length > 0 ? { links: asyncLinksForContract(contract) } : {}),
974
+ ...(contract.endpoint.status?.length > 0 ? { links: statusLinksForContract(contract) } : {})
975
+ };
976
+ } else {
977
+ operation.responses[String(contract.endpoint.successStatus)] = {
978
+ description: contract.endpoint.async?.length > 0 ? "Accepted" : "Success",
979
+ headers: successHeadersForContract(contract)
980
+ };
981
+ }
982
+
983
+ for (const cacheRule of contract.endpoint.cache || []) {
984
+ const status = String(cacheRule.notModified || 304);
985
+ operation.responses[status] = {
986
+ description: "Not Modified",
987
+ headers: {
988
+ [cacheRule.responseHeader]: { schema: { type: "string" } }
989
+ }
990
+ };
991
+ }
992
+
993
+ for (const [status, errors] of groupedErrorsByStatus(contract.errors)) {
994
+ if (operation.responses[status]) continue;
995
+ if (errors.length === 1) {
996
+ operation.responses[status] = { $ref: `#/components/responses/${errorResponseComponentName(errors[0])}` };
997
+ continue;
998
+ }
999
+ operation.responses[status] = {
1000
+ description: `Error (${status})`,
1001
+ content: {
1002
+ "application/json": {
1003
+ schema: { oneOf: errors.map((error) => refSchema(errorSchemaComponentName(error))) }
1004
+ }
1005
+ }
1006
+ };
1007
+ }
1008
+
1009
+ return operation;
1010
+ }
1011
+
1012
+ function errorResponseComponentName(error) {
1013
+ return `${componentNameFromErrorCode(error.code)}Response`;
1014
+ }
1015
+
1016
+ function errorSchemaComponentName(error) {
1017
+ return componentNameFromErrorCode(error.code);
1018
+ }
1019
+
1020
+ function titleFromIdentifier(id) {
1021
+ return id.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
1022
+ }
1023
+
1024
+ function errorResponseDescription(error) {
1025
+ return `${titleFromIdentifier(error.code)} (${error.status})`;
1026
+ }
1027
+
1028
+ function groupedErrorsByStatus(errors) {
1029
+ const grouped = new Map();
1030
+ for (const error of errors) {
1031
+ const status = String(error.status);
1032
+ if (!grouped.has(status)) grouped.set(status, []);
1033
+ grouped.get(status).push(error);
1034
+ }
1035
+ return grouped;
1036
+ }
1037
+
1038
+ export function generateOpenApi(graph, options = {}) {
1039
+ const contractGraph = generateApiContractGraph(graph, options);
1040
+ const contracts = options.capabilityId ? [contractGraph] : Object.values(contractGraph);
1041
+ const byId = indexStatements(graph);
1042
+ const document = {
1043
+ openapi: "3.1.0",
1044
+ info: {
1045
+ title: "Topogram API",
1046
+ version: "0.1.0"
1047
+ },
1048
+ paths: {},
1049
+ components: {
1050
+ schemas: {
1051
+ ErrorResponse: {
1052
+ type: "object",
1053
+ additionalProperties: false,
1054
+ required: ["code", "message"],
1055
+ properties: {
1056
+ code: { type: "string" },
1057
+ message: { type: "string" },
1058
+ details: { type: "object", additionalProperties: true }
1059
+ }
1060
+ }
1061
+ },
1062
+ responses: {},
1063
+ securitySchemes: {
1064
+ bearerAuth: { type: "http", scheme: "bearer" }
1065
+ }
1066
+ }
1067
+ };
1068
+
1069
+ const componentNames = {
1070
+ request: new Map(),
1071
+ response: new Map(),
1072
+ responseEnvelope: new Map()
1073
+ };
1074
+
1075
+ for (const contract of contracts) {
1076
+ if (contract.requestContract && contract.requestContract.transport.body.length > 0) {
1077
+ const requestSchemaName = componentNameFromShape(contract.requestContract.shape.id, "Request");
1078
+ componentNames.request.set(contract.capability.id, requestSchemaName);
1079
+ document.components.schemas[requestSchemaName] = requestBodySchema(contract);
1080
+ }
1081
+
1082
+ if (contract.responseContract) {
1083
+ const responseSchemaName = componentNameFromShape(contract.responseContract.shape.id, "Response");
1084
+ componentNames.response.set(contract.responseContract.shape.id, responseSchemaName);
1085
+ if (!document.components.schemas[responseSchemaName]) {
1086
+ document.components.schemas[responseSchemaName] = cloneSchema(
1087
+ contract.responseContract.collection ? contract.responseContract.itemJsonSchema : contract.responseContract.jsonSchema
1088
+ );
1089
+ }
1090
+
1091
+ if (
1092
+ (contract.responseContract.mode === "paged" || contract.responseContract.mode === "cursor") &&
1093
+ (contract.responseContract.pagination || contract.responseContract.cursor)
1094
+ ) {
1095
+ const envelopeSuffix = contract.responseContract.mode === "cursor" ? "CursorPageResponse" : "PageResponse";
1096
+ const envelopeSchemaName = componentNameFromShape(contract.responseContract.shape.id, envelopeSuffix);
1097
+ componentNames.responseEnvelope.set(contract.capability.id, envelopeSchemaName);
1098
+ if (!document.components.schemas[envelopeSchemaName]) {
1099
+ if (contract.responseContract.mode === "paged") {
1100
+ const pagination = contract.responseContract.pagination;
1101
+ document.components.schemas[envelopeSchemaName] = {
1102
+ type: "object",
1103
+ additionalProperties: false,
1104
+ required: [
1105
+ pagination.itemsProperty,
1106
+ pagination.pageProperty,
1107
+ pagination.pageSizeProperty,
1108
+ pagination.totalProperty
1109
+ ],
1110
+ properties: {
1111
+ [pagination.itemsProperty]: { type: "array", items: refSchema(responseSchemaName) },
1112
+ [pagination.pageProperty]: { type: "integer" },
1113
+ [pagination.pageSizeProperty]: { type: "integer" },
1114
+ [pagination.totalProperty]: { type: "integer" }
1115
+ }
1116
+ };
1117
+ } else {
1118
+ const cursor = contract.responseContract.cursor;
1119
+ const includeTotal = contract.responseContract.total?.included === true;
1120
+ document.components.schemas[envelopeSchemaName] = {
1121
+ type: "object",
1122
+ additionalProperties: false,
1123
+ required: ["items", cursor.responseNext],
1124
+ properties: {
1125
+ items: { type: "array", items: refSchema(responseSchemaName) },
1126
+ [cursor.responseNext]: { type: "string" },
1127
+ ...(cursor.responsePrev ? { [cursor.responsePrev]: { type: "string" } } : {}),
1128
+ ...(includeTotal ? { total: { type: "integer" } } : {})
1129
+ }
1130
+ };
1131
+ }
1132
+ }
1133
+ }
1134
+ }
1135
+
1136
+ for (const callback of contract.endpoint.callbacks || []) {
1137
+ const payloadShapeId = callback.payload?.id || null;
1138
+ if (!payloadShapeId) continue;
1139
+ const payloadShape = byId.get(payloadShapeId);
1140
+ if (!payloadShape) continue;
1141
+ const responseSchemaName = componentNameFromShape(payloadShapeId, "Response");
1142
+ componentNames.response.set(payloadShapeId, responseSchemaName);
1143
+ if (!document.components.schemas[responseSchemaName]) {
1144
+ document.components.schemas[responseSchemaName] = cloneSchema(generateShapeJsonSchema(payloadShape, byId));
1145
+ }
1146
+ }
1147
+
1148
+ for (const error of contract.errors) {
1149
+ const schemaName = errorSchemaComponentName(error);
1150
+ if (!document.components.schemas[schemaName]) {
1151
+ document.components.schemas[schemaName] = {
1152
+ allOf: [
1153
+ refSchema("ErrorResponse"),
1154
+ {
1155
+ type: "object",
1156
+ properties: {
1157
+ code: { type: "string", const: error.code }
1158
+ }
1159
+ }
1160
+ ],
1161
+ title: titleFromIdentifier(error.code)
1162
+ };
1163
+ }
1164
+
1165
+ const componentName = errorResponseComponentName(error);
1166
+ if (!document.components.responses[componentName]) {
1167
+ document.components.responses[componentName] = {
1168
+ description: errorResponseDescription(error),
1169
+ content: {
1170
+ "application/json": {
1171
+ schema: refSchema(schemaName)
1172
+ }
1173
+ }
1174
+ };
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ for (const contract of contracts) {
1180
+ const pathKey = contract.endpoint.path.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
1181
+ if (!document.paths[pathKey]) {
1182
+ document.paths[pathKey] = {};
1183
+ }
1184
+ document.paths[pathKey][contract.endpoint.method.toLowerCase()] = operationFromContract(contract, componentNames);
1185
+ }
1186
+
1187
+ const needsSecurity = contracts.some((contract) => contract.endpoint.auth && contract.endpoint.auth !== "none");
1188
+ if (!needsSecurity) {
1189
+ delete document.components.securitySchemes;
1190
+ if (Object.keys(document.components.schemas || {}).length === 0) delete document.components.schemas;
1191
+ if (Object.keys(document.components.responses || {}).length === 0) delete document.components.responses;
1192
+ if (Object.keys(document.components).length === 0) delete document.components;
1193
+ }
1194
+
1195
+ return document;
1196
+ }