backend-skeleton 1.0.0-beta.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 (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. package/stack/catalog/ngrok.yml +63 -0
@@ -0,0 +1,325 @@
1
+ // A6: renders an already-emitted feature contract as a standalone OpenAPI 3.1 document -- the
2
+ // EXIT direction A1 never built. A1/A2/A3 made `--openapi-file` load-bearing (python-fastapi,
3
+ // typescript-express and javascript-express all declare `api.operations: false` and depend on a
4
+ // real document for any contract at all), but nothing could get back OUT: a feature-scoped,
5
+ // single-module, fully `$ref`-inlined contract is more useful than the source repo's own whole-repo
6
+ // document for a Swagger UI page scoped to one feature, a client generator that chokes on `$ref`,
7
+ // or a mock server for one feature's operations.
8
+ //
9
+ // This module is PURE -- no I/O, no gate awareness (that is bin/bskel.mjs's cmdContractExport's
10
+ // job), mirroring how contracts/emit.mjs's buildContract() and contracts/openapi.mjs's
11
+ // reconcileModule() stay pure while the CLI layer owns files and gates.
12
+ //
13
+ // THE CENTRAL CONSTRAINT: this is a LOSSY, NARROW projection, and it must never synthesize what
14
+ // the contract does not know. Measured against the real 148-operation Team-IZ-Backend oracle
15
+ // document, the internal contract carries NO query parameters (33/148 real operations have them),
16
+ // NO header parameters (23/148), NO security requirements (148/148 real operations require bearer
17
+ // auth), no summary/description/tags, no per-status responses (22% of real operations do not
18
+ // return 200), and nothing at all for a non-JSON request body (5 real multipart operations).
19
+ // Every one of those is disclosed in prose (`info.description`) and machine-readably
20
+ // (`info.x-bskel-omitted`) rather than papered over -- see D-openapi-export in DECISIONS.md.
21
+ import { createHash } from 'node:crypto';
22
+ import { BSKEL_GENERATED_EXTENSION, PATH_PREFIX_RE } from './openapi.mjs';
23
+
24
+ // 3.1 ONLY, and deliberately no 3.0 mode even as a flag -- this is a cited exclusion, not
25
+ // laziness. Verified by executing the official meta-schemas, not by reading prose: 3.0's
26
+ // `Operation` has `required: ["responses"]`, which would force synthesizing a response object for
27
+ // an operation the contract knows nothing about; 3.0's Schema Object types `exclusiveMinimum`/
28
+ // `exclusiveMaximum` as BOOLEANS while contracts/openapi.mjs's COPIED_KEYWORDS copies them as the
29
+ // NUMBERS a 3.1 source document used, so emitting one into a 3.0 document silently inverts its
30
+ // meaning; and 3.0's Schema Object has no `const` at all and restricts `type` to a string enum
31
+ // with no `"null"` member, both of which a projected contract schema can legitimately contain.
32
+ // See D-openapi-export's EXCLUDED section in DECISIONS.md.
33
+ export const OPENAPI_TARGET_VERSION = '3.1.0';
34
+
35
+ export const STATUS_CODE_MODES = Object.freeze(['range', 'literal']);
36
+
37
+ const JSON_MEDIA_TYPE = 'application/json';
38
+ const HTTP_VERBS = Object.freeze(new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']));
39
+
40
+ // OpenAPI 3.1 REQUIRES `description` on a Response Object (`$defs.response.required:
41
+ // ["description"]`, confirmed by executing the official 2022-10-07 meta-schema). These two strings
42
+ // therefore exist because the FORMAT demands a description, not because the contract has one --
43
+ // so they describe the projection itself and make no claim about the API. Same "describe the
44
+ // method, not an unchecked fact about the repo" discipline D-openapi-reconciliation applied when
45
+ // it rewrote `api_surface_source`.
46
+ const SUCCESS_RESPONSE_DESCRIPTION = 'Success. The source contract records the union of every documented 2xx JSON body for this operation, with no per-status detail -- see info.x-bskel-omitted.';
47
+ const ERROR_RESPONSE_DESCRIPTION = 'Error. The source contract records the union of every documented 4xx/5xx JSON body for this operation, with no per-status detail -- see info.x-bskel-omitted.';
48
+
49
+ // Structural omissions: the contract format carries none of these for ANY contract, so they are
50
+ // always disclosed. Phrased as "this projection cannot represent X", never as "the real API has X
51
+ // that we dropped" -- this tool has no way to know the latter, and asserting an unverified fact
52
+ // about the target repo is exactly the dishonesty D-openapi-reconciliation's §7 addendum fixed.
53
+ const STRUCTURAL_OMISSIONS = Object.freeze([
54
+ 'descriptions',
55
+ 'header-parameters',
56
+ 'non-json-media-types',
57
+ 'per-status-responses',
58
+ 'query-parameters',
59
+ 'security',
60
+ 'summaries',
61
+ 'tags',
62
+ ]);
63
+
64
+ const OMISSION_PROSE = Object.freeze({
65
+ descriptions: 'field-level descriptions/titles/examples (contracts/openapi.mjs drops them as DROPPED_KEYWORDS while inlining a schema)',
66
+ 'error-schemas': 'a JSON error-body schema for at least one operation',
67
+ 'header-parameters': 'header parameters (the contract carries none, for any operation)',
68
+ 'non-json-media-types': 'request/response bodies in any media type other than application/json (multipart uploads in particular are invisible to the contract)',
69
+ 'per-status-responses': 'per-status responses -- the contract collapses every 2xx body into one union and every 4xx/5xx body into another, and records no status codes at all',
70
+ 'query-parameters': 'query parameters (the contract carries none, for any operation)',
71
+ 'request-body-schemas': 'a JSON request-body schema for at least one operation that takes a body',
72
+ 'response-schemas': 'a JSON success-body schema for at least one operation',
73
+ security: 'security requirements and security schemes -- `security` is OMITTED, never emitted as `[]`, because an empty array is a positive claim that no authentication is required, and this projection has no way to know that',
74
+ summaries: 'operation summaries and tags (nothing in the contract records either; they are NOT synthesized from operationId or module names)',
75
+ tags: 'operation tags',
76
+ });
77
+
78
+ // Derived from the contract's ACTUAL content, not hardcoded -- an operation that takes a body but
79
+ // has no projected schema, or has no response/error schema, each add their own entry, so the list
80
+ // says what is missing from THIS document rather than reciting a fixed disclaimer.
81
+ export function collectOmissions(contract) {
82
+ const omissions = new Set(STRUCTURAL_OMISSIONS);
83
+ for (const op of Object.values(contract.operations)) {
84
+ if (!op.requestBodySchema && (op.body === true || op.body === 'unknown')) omissions.add('request-body-schemas');
85
+ if (!op.responseSchema) omissions.add('response-schemas');
86
+ if (!op.errorSchema) omissions.add('error-schemas');
87
+ }
88
+ return [...omissions].sort();
89
+ }
90
+
91
+ function renderDescription(contract, omissions, statusCodes) {
92
+ const lines = [
93
+ `Generated by \`bskel contract export\` from feature ${contract.feature_id}'s own machine-readable contract (\`sbf_contract: "${contract.sbf_contract}"\`, completeness: ${contract.completeness.status}).`,
94
+ '',
95
+ 'THIS IS A NARROW, LOSSY PROJECTION OF ONE FEATURE, NOT A DESCRIPTION OF THE WHOLE API.',
96
+ 'It describes exactly what the source contract knows and nothing else. The following are NOT',
97
+ 'represented here, and their absence is a limit of this projection -- never evidence that the',
98
+ 'real API lacks them:',
99
+ '',
100
+ ...omissions.map((key) => ` - ${key}: ${OMISSION_PROSE[key] ?? key}`),
101
+ '',
102
+ statusCodes === 'literal'
103
+ ? 'Status codes: `200` is a bskel-chosen stand-in for "the documented success body". The source contract records no status codes whatsoever, so `200` here is NOT a claim that this operation returns 200. Re-export with `--status-codes range` for the spec-legal `2XX` range key, which invents nothing.'
104
+ : 'Status codes: `2XX` and `default` are OpenAPI 3.1 range keys. They are used because the source contract records no status codes whatsoever, so any concrete code would be invented.',
105
+ '',
106
+ 'The same information, machine-readable, is in `info.x-bskel-omitted`.',
107
+ ];
108
+ return lines.join('\n');
109
+ }
110
+
111
+ // Every `{name}` in the path template, in order, deduplicated (OpenAPI forbids two parameters
112
+ // sharing name+location). The schema comes from the contract's own `pathParams.properties`; the
113
+ // `{}` fallback for a name the contract has no property for is "unconstrained", which is both
114
+ // honest and the minimum the 3.1 meta-schema accepts (`$defs.parameter`'s
115
+ // `oneOf: [{required:["schema"]}, {required:["content"]}]` means a parameter MUST carry one or the
116
+ // other -- confirmed by executing the real schema).
117
+ function buildPathParameters(op) {
118
+ const props = op.pathParams && typeof op.pathParams === 'object' && !Array.isArray(op.pathParams)
119
+ ? (op.pathParams.properties ?? {})
120
+ : {};
121
+ const seen = new Set();
122
+ const params = [];
123
+ for (const match of String(op.path).matchAll(/\{([^{}/]+)\}/g)) {
124
+ const name = match[1];
125
+ if (seen.has(name)) continue;
126
+ seen.add(name);
127
+ params.push({
128
+ name,
129
+ in: 'path',
130
+ // 3.1's `styles-for-path` subschema makes this `{const: true}` AND required -- a path
131
+ // parameter that omits it, or sets it false, fails the real meta-schema. Also simply true.
132
+ required: true,
133
+ schema: Object.hasOwn(props, name) ? props[name] : {},
134
+ });
135
+ }
136
+ return params;
137
+ }
138
+
139
+ // The `content: {'application/json': {}}` shape (a media-type entry with NO schema) is load-bearing
140
+ // and deliberate, not an oversight: it is the only way to say "this operation takes a JSON body,
141
+ // whose shape this contract does not know" without inventing one. Emitting `{type: 'object'}` there
142
+ // would be a fabricated schema, AND would break the round-trip invariant (re-importing would
143
+ // produce a `requestBodySchema` the original contract never had).
144
+ function buildRequestBody(op) {
145
+ if (op.requestBodySchema) {
146
+ return {
147
+ required: op.requestBodyRequired === true,
148
+ content: { [JSON_MEDIA_TYPE]: { schema: op.requestBodySchema } },
149
+ };
150
+ }
151
+ if (op.body === true) return { required: true, content: { [JSON_MEDIA_TYPE]: {} } };
152
+ if (op.body === 'unknown') return { content: { [JSON_MEDIA_TYPE]: {} } };
153
+ // op.body === false: the scan positively determined this operation takes no @RequestBody.
154
+ return null;
155
+ }
156
+
157
+ // `range` (default) uses `2XX`/`default`, both legal per the official 3.1 meta-schema's own
158
+ // `responses` patternProperties (`^[1-5](?:[0-9]{2}|XX)$` plus an explicit `default` property) --
159
+ // verified by executing that schema, not assumed. `literal` uses `200`/`default` for tooling that
160
+ // cannot handle range keys, at the cost of a stand-in the contract cannot back up. An operation
161
+ // with neither a response nor an error schema gets NO `responses` key at all: 3.1 does not require
162
+ // one (`$defs.operation` has no `required` array -- also verified by execution), and `responses: {}`
163
+ // is illegal anyway (`minProperties: 1`). Omitting is both legal and honest; guessing a status
164
+ // would be neither.
165
+ function buildResponses(op, statusCodes) {
166
+ const responses = new Map();
167
+ if (op.responseSchema) {
168
+ responses.set(statusCodes === 'literal' ? '200' : '2XX', {
169
+ description: SUCCESS_RESPONSE_DESCRIPTION,
170
+ content: { [JSON_MEDIA_TYPE]: { schema: op.responseSchema } },
171
+ });
172
+ }
173
+ if (op.errorSchema) {
174
+ responses.set('default', {
175
+ description: ERROR_RESPONSE_DESCRIPTION,
176
+ content: { [JSON_MEDIA_TYPE]: { schema: op.errorSchema } },
177
+ });
178
+ }
179
+ return responses.size > 0 ? Object.fromEntries(responses) : null;
180
+ }
181
+
182
+ // The self-import guard's own key material. Byte-identical to what cmdContractEmit writes to disk
183
+ // (`JSON.stringify(contract, null, 2) + '\n'`), so this is the same value lib/gate-definitions.mjs's
184
+ // `contract.recompute()` computes as `contract_hash` via sha256File -- one number identifying
185
+ // exactly which contract state produced this document, not a second, parallel notion of identity.
186
+ // test/contract-export.test.mjs asserts that equality against a real emitted file rather than
187
+ // leaving it as a claim.
188
+ export function contractSha256(contract) {
189
+ return createHash('sha256').update(`${JSON.stringify(contract, null, 2)}\n`).digest('hex');
190
+ }
191
+
192
+ // Candidate global path prefixes implied by a scan report's `path_prefix_signals` (A1 §7 --
193
+ // `configurePathMatch`/`context-path` carry a literal `prefix`, `paths-to-match` carries a
194
+ // `pattern` like `/api/v0/**`). A pattern of `/**` yields nothing (it is not narrower than the
195
+ // whole API and implies no prefix). PATH_PREFIX_RE is reused rather than re-derived so a signal
196
+ // value that isn't a clean segment path is skipped instead of guessed at.
197
+ export function pathPrefixCandidates(signals) {
198
+ const out = new Set();
199
+ for (const signal of signals ?? []) {
200
+ if (typeof signal !== 'object' || signal === null) continue;
201
+ let value = null;
202
+ if (typeof signal.prefix === 'string') value = signal.prefix;
203
+ else if (typeof signal.pattern === 'string') value = signal.pattern.replace(/\/\*+$/, '');
204
+ if (typeof value !== 'string') continue;
205
+ value = value.replace(/\/+$/, '');
206
+ if (!PATH_PREFIX_RE.test(value)) continue;
207
+ out.add(value);
208
+ }
209
+ return [...out].sort();
210
+ }
211
+
212
+ // A candidate is "unreflected" if ANY of the contract's own operation paths does not sit under it.
213
+ // Deliberately ANY, not ALL: a partially-reconciled contract (matched operations path-corrected,
214
+ // a `drift`/`missing` one left at its uncorrected scan path) is exactly the dangerous mixed case,
215
+ // and handing half-right paths to a client generator is no safer than handing wholly-wrong ones.
216
+ // Segment-boundary safe -- `/api/v0` never counts `/api/v0abc` as prefixed.
217
+ export function unreflectedPathPrefixes(contract, candidates) {
218
+ const paths = Object.values(contract.operations).map((op) => String(op.path));
219
+ return candidates.filter((prefix) => paths.some((p) => p !== prefix && !p.startsWith(`${prefix}/`)));
220
+ }
221
+
222
+ export function buildOpenApiDocument({ contract, snapshot = null, options = {} }) {
223
+ const statusCodes = options.statusCodes ?? 'range';
224
+ if (!STATUS_CODE_MODES.includes(statusCodes)) {
225
+ return { ok: false, error: `unknown status-codes mode "${statusCodes}" -- expected one of: ${STATUS_CODE_MODES.join(', ')}` };
226
+ }
227
+
228
+ const operationIds = Object.keys(contract.operations);
229
+ if (operationIds.length === 0) {
230
+ return { ok: false, error: 'this contract has zero operations -- there is nothing to export' };
231
+ }
232
+
233
+ // A Map, never a plain object, for the same structural reason contracts/openapi.mjs indexes a
234
+ // parsed document with Maps: these keys come from a JSON file that is hand-editable on disk.
235
+ // (Every real path also starts with "/", which already rules out `__proto__`, but the Map makes
236
+ // that a property of the data structure rather than of an assumption about the input.)
237
+ const pathItems = new Map();
238
+ const claimedRoutes = new Map();
239
+
240
+ for (const operationId of operationIds) {
241
+ const op = contract.operations[operationId];
242
+ const route = String(op.path);
243
+ if (!route.startsWith('/')) {
244
+ return { ok: false, error: `operation "${operationId}" has path "${route}", which does not start with "/" -- an OpenAPI Paths Object key must (verified against the official 3.1 meta-schema)` };
245
+ }
246
+ const verb = String(op.verb).toLowerCase();
247
+ if (!HTTP_VERBS.has(verb)) {
248
+ return { ok: false, error: `operation "${operationId}" has verb "${op.verb}", which is not an HTTP method OpenAPI recognizes` };
249
+ }
250
+
251
+ const routeKey = `${verb} ${route}`;
252
+ if (claimedRoutes.has(routeKey)) {
253
+ return { ok: false, error: `operations "${claimedRoutes.get(routeKey)}" and "${operationId}" both map to ${op.verb} ${route} -- an OpenAPI path item holds at most one operation per verb, so this contract cannot be exported until the collision is resolved (it usually means one of the two kept an uncorrected scan path; re-run \`bskel contract emit --openapi-file ...\`)` };
254
+ }
255
+ claimedRoutes.set(routeKey, operationId);
256
+
257
+ const operation = { operationId };
258
+ const parameters = buildPathParameters(op);
259
+ if (parameters.length > 0) operation.parameters = parameters;
260
+ const requestBody = buildRequestBody(op);
261
+ if (requestBody) operation.requestBody = requestBody;
262
+ const responses = buildResponses(op, statusCodes);
263
+ if (responses) operation.responses = responses;
264
+ // NOTE what is deliberately NOT set here: `summary`, `description`, `tags`, `security`.
265
+ // `security: []` in particular is spec-legal (confirmed by executing the meta-schema) and
266
+ // would be the easy thing to emit -- which is exactly why it is refused: it asserts "no
267
+ // authentication required", and in the real oracle document 148/148 operations require
268
+ // bearer auth. Omitting the key means "unspecified", which is the truth.
269
+
270
+ if (!pathItems.has(route)) pathItems.set(route, new Map());
271
+ pathItems.get(route).set(verb, operation);
272
+ }
273
+
274
+ const omissions = collectOmissions(contract);
275
+ const sha256 = contractSha256(contract);
276
+
277
+ const generated = {
278
+ feature_id: contract.feature_id,
279
+ feature_uid: contract.feature_uid,
280
+ sbf_contract: contract.sbf_contract,
281
+ completeness: contract.completeness.status,
282
+ contract_sha256: sha256,
283
+ status_codes: statusCodes,
284
+ exported_by: options.exportedBy ?? 'bskel',
285
+ };
286
+ // Provenance only, and only when this feature actually reconciled against a real document --
287
+ // says WHICH oracle the paths in here were corrected against, which is the first thing anyone
288
+ // auditing an exported path wants to know.
289
+ if (snapshot && snapshot.source && typeof snapshot.source === 'object') {
290
+ generated.reconciled_against = {
291
+ file: snapshot.source.file ?? null,
292
+ sha256: snapshot.source.sha256 ?? null,
293
+ outside_repo: snapshot.source.outside_repo === true,
294
+ };
295
+ }
296
+
297
+ const document = {
298
+ openapi: OPENAPI_TARGET_VERSION,
299
+ info: {
300
+ title: `${contract.feature_id} (bskel contract export)`,
301
+ // OpenAPI requires `info.version` to be a string, and the contract carries no API
302
+ // version. A 12-char prefix of the contract hash is a real content identifier (stable
303
+ // across re-exports of the same contract, different the moment the contract changes)
304
+ // rather than an invented semver that would read as a claim about the API's own
305
+ // versioning. Explained in `description` so nobody mistakes it for one.
306
+ version: sha256.slice(0, 12),
307
+ description: renderDescription(contract, omissions, statusCodes),
308
+ [BSKEL_GENERATED_EXTENSION]: generated,
309
+ 'x-bskel-omitted': omissions,
310
+ },
311
+ paths: Object.fromEntries([...pathItems].map(([route, verbs]) => [route, Object.fromEntries(verbs)])),
312
+ };
313
+
314
+ return {
315
+ ok: true,
316
+ document,
317
+ omissions,
318
+ contractSha256: sha256,
319
+ statusCodes,
320
+ // True when `literal` actually produced at least one stand-in `200` -- lets the CLI print
321
+ // the stand-in warning ONCE, and only when it is actually relevant, rather than per
322
+ // operation or unconditionally.
323
+ literalStatusStandIn: statusCodes === 'literal' && Object.values(contract.operations).some((op) => Boolean(op.responseSchema)),
324
+ };
325
+ }