backend-skeleton 1.0.0-beta.1 → 1.0.0-beta.2

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.
@@ -11,15 +11,19 @@
11
11
  // reconcileModule() stay pure while the CLI layer owns files and gates.
12
12
  //
13
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.
14
+ // the contract does not know. A7 (D-openapi-passthrough) narrowed that lossiness for four fields --
15
+ // query/header/cookie parameters, security (+securitySchemes), summary, and tags are now emitted,
16
+ // but ONLY when a real source document (--openapi-file) licensed it for that EXACT operation; where
17
+ // no source stated one, the key is still omitted, meaning "unspecified". A8 (D-openapi-per-status)
18
+ // extends the same discipline to per-status responses (additive to, never replacing, the
19
+ // responseSchema/errorSchema union) and non-JSON request media types. Operation-level
20
+ // `description` remains excluded -- measured too expensive to default-on (2,442.7 bytes/operation
21
+ // average, larger than every other field this projection copies combined), still disclosed as
22
+ // structural. Every omission is disclosed in prose (`info.description`) and machine-readably
23
+ // (`info.x-bskel-omitted`) rather than papered over -- see D-openapi-export, D-openapi-passthrough,
24
+ // and D-openapi-per-status in DECISIONS.md.
21
25
  import { createHash } from 'node:crypto';
22
- import { BSKEL_GENERATED_EXTENSION, PATH_PREFIX_RE } from './openapi.mjs';
26
+ import { BSKEL_GENERATED_EXTENSION, BSKEL_PASSTHROUGH_EXTENSION, PATH_PREFIX_RE, RESPONSE_STATUS_KEY_RE, MEDIA_TYPE_RE, PER_STATUS_NO_DESCRIPTION_STANDIN, SUCCESS_STATUS_RE, ERROR_STATUS_RE, DEFAULT_STATUS_KEY } from './openapi.mjs';
23
27
 
24
28
  // 3.1 ONLY, and deliberately no 3.0 mode even as a flag -- this is a cited exclusion, not
25
29
  // laziness. Verified by executing the official meta-schemas, not by reading prose: 3.0's
@@ -50,58 +54,122 @@ const ERROR_RESPONSE_DESCRIPTION = 'Error. The source contract records the union
50
54
  // always disclosed. Phrased as "this projection cannot represent X", never as "the real API has X
51
55
  // that we dropped" -- this tool has no way to know the latter, and asserting an unverified fact
52
56
  // about the target repo is exactly the dishonesty D-openapi-reconciliation's §7 addendum fixed.
57
+ //
58
+ // A7: query/header/cookie parameters, security, summaries, and tags moved OUT of this list -- they
59
+ // are now real emitted content when a source document licensed them, so they only belong in the
60
+ // DERIVED (ANY-based) set collectOmissions() builds below. A8 moves `per-status-responses` and
61
+ // `non-json-request-media-types` (renamed from `non-json-media-types`) out the same way. What
62
+ // stays structural: `descriptions` (field-level AND operation-level -- the latter measured and
63
+ // deliberately excluded, not merely unbuilt), `path-parameter-schemas` (path-param schemas come
64
+ // from this contract's own name heuristic, never from a source document, even when --openapi-file
65
+ // was given -- see the real `batchRequestId` finding in D-openapi-passthrough), `vendor-extensions`
66
+ // (x-* keys on an operation are never copied -- excluded in principle, not by cap or failure, since
67
+ // their semantics are tool-specific), and two A8 additions: `non-json-response-schemas` (a
68
+ // non-JSON response media type's NAME is copied via a per-status entry's `mediaTypes`, but its
69
+ // SHAPE is never projected -- 0/674 real occurrences, so building that machinery would violate
70
+ // this project's own "don't build for zero real cases" discipline) and `response-headers`
71
+ // (response `headers`/`links` -- 0/694 real occurrences, a genuinely visible gap only now that
72
+ // per-status responses look complete).
53
73
  const STRUCTURAL_OMISSIONS = Object.freeze([
54
74
  'descriptions',
55
- 'header-parameters',
56
- 'non-json-media-types',
57
- 'per-status-responses',
58
- 'query-parameters',
59
- 'security',
60
- 'summaries',
61
- 'tags',
75
+ 'non-json-response-schemas',
76
+ 'path-parameter-schemas',
77
+ 'response-headers',
78
+ 'vendor-extensions',
62
79
  ]);
63
80
 
64
81
  const OMISSION_PROSE = Object.freeze({
65
- descriptions: 'field-level descriptions/titles/examples (contracts/openapi.mjs drops them as DROPPED_KEYWORDS while inlining a schema)',
82
+ 'cookie-parameters': 'cookie parameters, for at least one operation that does not carry a fully-copied set (never emitted at all when --openapi-file was not given, or the source document declared none)',
83
+ descriptions: 'field-level descriptions/titles/examples (contracts/openapi.mjs drops them as DROPPED_KEYWORDS while inlining a schema), and operation-level `description` -- measured and deliberately excluded (real average 2,442.7 bytes/operation, larger than every other field this projection copies combined); if ever built, it must be opt-in behind a flag, unlike everything else this projection copies by default',
66
84
  '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)',
85
+ 'header-parameters': 'header parameters, for at least one operation that does not carry a fully-copied set (never emitted at all when --openapi-file was not given, or the source document declared none)',
86
+ 'non-json-request-media-types': 'the media type of the request body, for at least one operation that takes one -- a non-application/json request media type is emitted only when a real source document declared one for that exact operation, copied byte-for-byte; otherwise this document shows a JSON media-type entry because that is all the contract knows, never because the real body is known to be JSON',
87
+ 'non-json-response-schemas': 'a JSON Schema for any response body in a media type other than application/json -- the media type is named where a source document declared one for that status, but its shape is never projected',
88
+ 'path-parameter-schemas': 'path parameter schemas -- always derived from this contract\'s own name heuristic (a trailing "Id" is assumed to be a UUID), never from a source document even when --openapi-file was given; a real, small false-negative of this heuristic is known and disclosed, not fixed, by this projection',
89
+ 'per-status-responses': 'per-status responses, for at least one operation -- that operation\'s entry collapses every documented 2xx body into one `2XX` union and every 4xx/5xx body into one `default` union, and records no real status codes. Where a real source document (--openapi-file) documented statuses for an operation, its own status codes and descriptions are emitted verbatim instead; nothing is invented for an operation the source said nothing about',
90
+ 'query-parameters': 'query parameters, for at least one operation that does not carry a fully-copied set (never emitted at all when --openapi-file was not given, or the source document declared none)',
71
91
  'request-body-schemas': 'a JSON request-body schema for at least one operation that takes a body',
92
+ 'response-headers': 'response headers and links on any documented status -- copyable in principle, not built; a per-status response here describes its body and nothing else',
72
93
  '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',
94
+ security: 'security requirements and/or security schemes, for at least one operation -- `security` is emitted ONLY when a real source document (--openapi-file) stated one for that EXACT operation, byte-for-byte, including a literal `[]` (a genuine claim that no authentication is required); where no source document was given, or the source stated nothing for that operation, the key is omitted, meaning "unspecified", never invented',
95
+ summaries: 'operation summaries, for at least one operation -- copied from a real source document when present, never synthesized from operationId or module names',
96
+ tags: 'operation tags, for at least one operation -- copied verbatim from a real source document when present',
97
+ 'vendor-extensions': 'vendor extensions (`x-*` keys) on any operation -- copyable in principle, excluded because their semantics are tool-specific',
76
98
  });
77
99
 
100
+ function hasSourceParamIn(op, loc) {
101
+ return Array.isArray(op.sourceParameters) && op.sourceParameters.some((p) => p.in === loc);
102
+ }
103
+
78
104
  // Derived from the contract's ACTUAL content, not hardcoded -- an operation that takes a body but
79
105
  // has no projected schema, or has no response/error schema, each add their own entry, so the list
80
106
  // says what is missing from THIS document rather than reciting a fixed disclaimer.
107
+ //
108
+ // A7: query/header/cookie parameters, security, summaries, and tags follow the SAME "ANY, not ALL"
109
+ // doctrine unreflectedPathPrefixes() already established -- present whenever at least one exported
110
+ // operation lacks that field's passthrough, whether because no source document was given at all, the
111
+ // source document genuinely declared none for that operation, or copying it failed and produced a
112
+ // CONTRACT_OPENAPI_*_UNRESOLVED warning. In every one of those cases a consumer cannot trust that
113
+ // EVERY operation carries it, which is exactly what this disclosure exists to say.
81
114
  export function collectOmissions(contract) {
82
115
  const omissions = new Set(STRUCTURAL_OMISSIONS);
83
116
  for (const op of Object.values(contract.operations)) {
84
117
  if (!op.requestBodySchema && (op.body === true || op.body === 'unknown')) omissions.add('request-body-schemas');
85
118
  if (!op.responseSchema) omissions.add('response-schemas');
86
119
  if (!op.errorSchema) omissions.add('error-schemas');
120
+ if (!hasSourceParamIn(op, 'query')) omissions.add('query-parameters');
121
+ if (!hasSourceParamIn(op, 'header')) omissions.add('header-parameters');
122
+ if (!hasSourceParamIn(op, 'cookie')) omissions.add('cookie-parameters');
123
+ if (!op.sourceSecurity) omissions.add('security');
124
+ if (!op.sourceSummary) omissions.add('summaries');
125
+ if (!op.sourceTags) omissions.add('tags');
126
+ // A8: per-status responses -- ANY-based, same doctrine as every check above.
127
+ if (!(op.sourceResponses && typeof op.sourceResponses === 'object' && Object.keys(op.sourceResponses).length > 0)) {
128
+ omissions.add('per-status-responses');
129
+ }
130
+ // A8: non-JSON request media type -- only meaningful for an operation that takes a body at
131
+ // all (same gate request-body-schemas above already uses); an operation with body===false
132
+ // genuinely has no request media type to disclose.
133
+ if ((op.body === true || op.body === 'unknown') && !op.requestBodySchema && !op.sourceRequestBody) {
134
+ omissions.add('non-json-request-media-types');
135
+ }
87
136
  }
88
137
  return [...omissions].sort();
89
138
  }
90
139
 
140
+ // A8: which of the three status-codes variants applies -- none of this contract's response/error-
141
+ // bearing operations carry per-status source data (the pre-A8 union-only story, unchanged wording),
142
+ // all of them do (every real status code in this export is a copied fact, not an invented one), or
143
+ // a mix (some real, some union stand-ins) -- computed from the contract's own content, never
144
+ // guessed.
145
+ function statusCodesLine(contract, statusCodes) {
146
+ const opsWithSchema = Object.values(contract.operations).filter((op) => op.responseSchema || op.errorSchema);
147
+ const perStatusCount = opsWithSchema.filter((op) => op.sourceResponses && typeof op.sourceResponses === 'object' && Object.keys(op.sourceResponses).length > 0).length;
148
+ if (opsWithSchema.length > 0 && perStatusCount === opsWithSchema.length) {
149
+ return 'Status codes: every response/error-bearing operation in this export carries the source document\'s own real status codes, copied verbatim (--openapi-file) -- nothing here is invented.';
150
+ }
151
+ if (perStatusCount > 0) {
152
+ return statusCodes === 'literal'
153
+ ? `Status codes: ${perStatusCount} of ${opsWithSchema.length} response/error-bearing operation(s) carry the source document's own real status codes, copied verbatim; the rest have no per-status source data, so \`200\` is a bskel-chosen stand-in for those -- see info.x-bskel-generated.passthrough for exactly which is which.`
154
+ : `Status codes: ${perStatusCount} of ${opsWithSchema.length} response/error-bearing operation(s) carry the source document's own real status codes, copied verbatim; the rest have no per-status source data, so \`2XX\`/\`default\` range keys are used for those, inventing nothing but recording no real code -- see info.x-bskel-generated.passthrough for exactly which is which.`;
155
+ }
156
+ return statusCodes === 'literal'
157
+ ? '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.'
158
+ : '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.';
159
+ }
160
+
91
161
  function renderDescription(contract, omissions, statusCodes) {
92
162
  const lines = [
93
163
  `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
164
  '',
95
165
  '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:',
166
+ 'It describes exactly what the source contract knows and nothing else. The following are either',
167
+ 'never represented here, or not represented for at least one operation in this document -- their',
168
+ 'absence is a limit of this projection, never evidence that the real API lacks them:',
99
169
  '',
100
170
  ...omissions.map((key) => ` - ${key}: ${OMISSION_PROSE[key] ?? key}`),
101
171
  '',
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.',
172
+ statusCodesLine(contract, statusCodes),
105
173
  '',
106
174
  'The same information, machine-readable, is in `info.x-bskel-omitted`.',
107
175
  ];
@@ -136,22 +204,133 @@ function buildPathParameters(op) {
136
204
  return params;
137
205
  }
138
206
 
207
+ // A7: appends the operation's copied `sourceParameters` (query/header/cookie) to the path-derived
208
+ // list above, deduplicated on (name,in) with the PATH-derived ones winning -- the exporter enforces
209
+ // uniqueness itself, since the meta-schema demonstrably does not (confirmed by executing it: two
210
+ // parameters sharing name+in both validate `true`). A copied parameter whose own `schema` failed to
211
+ // resolve (kept in sourceParameters without a `schema` key, see contracts/openapi.mjs's
212
+ // copyParameter) is emitted with `schema: {}` here -- not dropped -- the same honest-minimum
213
+ // fallback buildPathParameters() already uses for a path parameter with no known schema.
214
+ function buildOperationParameters(op) {
215
+ const params = buildPathParameters(op);
216
+ const seen = new Set(params.map((p) => `${p.in} ${p.name}`));
217
+ if (Array.isArray(op.sourceParameters)) {
218
+ for (const p of op.sourceParameters) {
219
+ const key = `${p.in} ${p.name}`;
220
+ if (seen.has(key)) continue;
221
+ seen.add(key);
222
+ params.push(Object.hasOwn(p, 'schema') ? p : { ...p, schema: {} });
223
+ }
224
+ }
225
+ return params;
226
+ }
227
+
139
228
  // The `content: {'application/json': {}}` shape (a media-type entry with NO schema) is load-bearing
140
229
  // and deliberate, not an oversight: it is the only way to say "this operation takes a JSON body,
141
230
  // whose shape this contract does not know" without inventing one. Emitting `{type: 'object'}` there
142
231
  // would be a fabricated schema, AND would break the round-trip invariant (re-importing would
143
232
  // produce a `requestBodySchema` the original contract never had).
233
+ //
234
+ // A8: non-JSON media types (op.sourceRequestBody) merge in regardless of the JSON branch above -- a
235
+ // real operation can legally accept BOTH application/json and multipart/form-data. This is also
236
+ // where the real, pre-existing `body:false`-but-a-real-body-exists disagreement (see
237
+ // D-openapi-per-status -- multipart handlers use @RequestPart, not @RequestBody, so the scan
238
+ // misses them) becomes visible for multipart the same way it already was for JSON: `body:false` +
239
+ // a source document that documented a body wins, extending a shipped behavior rather than
240
+ // inventing one. Revalidated through MEDIA_TYPE_RE the same defensive way buildPerStatusResponses
241
+ // revalidates status keys below (the contract file is hand-editable JSON on disk -- S5);
242
+ // JSON_MEDIA_TYPE itself is excluded explicitly, so this loop cannot introduce a second
243
+ // "application/json" entry even from a hand-edited file.
244
+ //
245
+ // A real bug found by executing this against a multipart-only fixture, not by inspection: the
246
+ // `op.body === true/'unknown'` bare-JSON fallback below is a SCAN-driven guess for "we don't know
247
+ // this body's shape at all" -- but when `op.sourceRequestBody` is present, reconciliation already
248
+ // positively determined this operation's ONLY media type(s) from a real document, and none of them
249
+ // was `application/json`. Applying the guess on top of that positive fact would emit a second,
250
+ // contradicting "maybe it's also unconstrained JSON" entry the source document never declared --
251
+ // worse than the pre-A8 gap it extends, not a continuation of it. So the fallback is suppressed
252
+ // whenever real per-operation media-type information already exists, JSON or not.
144
253
  function buildRequestBody(op) {
254
+ const hasSourceMediaTypeInfo = op.sourceRequestBody && typeof op.sourceRequestBody === 'object' && !Array.isArray(op.sourceRequestBody);
255
+ let body = null;
145
256
  if (op.requestBodySchema) {
146
- return {
147
- required: op.requestBodyRequired === true,
148
- content: { [JSON_MEDIA_TYPE]: { schema: op.requestBodySchema } },
257
+ body = { required: op.requestBodyRequired === true, content: { [JSON_MEDIA_TYPE]: { schema: op.requestBodySchema } } };
258
+ } else if (op.body === true && !hasSourceMediaTypeInfo) {
259
+ body = { required: true, content: { [JSON_MEDIA_TYPE]: {} } };
260
+ } else if (op.body === 'unknown' && !hasSourceMediaTypeInfo) {
261
+ body = { content: { [JSON_MEDIA_TYPE]: {} } };
262
+ }
263
+ // op.body === false, or op.body true/'unknown' with real sourceRequestBody info taking
264
+ // precedence over the scan-driven guess: body stays null here, filled in below if
265
+ // sourceRequestBody has real content.
266
+
267
+ const srcContent = hasSourceMediaTypeInfo ? op.sourceRequestBody.content : null;
268
+ if (srcContent && typeof srcContent === 'object' && !Array.isArray(srcContent)) {
269
+ for (const mt of Object.keys(srcContent)) {
270
+ if (mt === JSON_MEDIA_TYPE || !MEDIA_TYPE_RE.test(mt)) continue;
271
+ const entry = srcContent[mt];
272
+ const mediaTypeObject = {};
273
+ if (entry && typeof entry === 'object' && !Array.isArray(entry) && entry.schema && typeof entry.schema === 'object' && !Array.isArray(entry.schema)) {
274
+ mediaTypeObject.schema = entry.schema;
275
+ }
276
+ if (!body) body = { content: {} };
277
+ body.content[mt] = mediaTypeObject;
278
+ }
279
+ if (body && op.sourceRequestBody.required === true) body.required = true;
280
+ }
281
+ return body;
282
+ }
283
+
284
+ // A8: the per-status path -- returns null (never {}) when op.sourceResponses is absent OR,
285
+ // defensively, when nothing in it survives revalidation (reconciliation itself never produces an
286
+ // empty sourceResponses, see contracts/openapi.mjs's applyPerStatusResponses' anyEntry check; this
287
+ // branch only matters for a hand-edited contract file -- S5/persistence-integrity). Revalidated
288
+ // through RESPONSE_STATUS_KEY_RE/MEDIA_TYPE_RE the same defensive way buildRequestBody() revalidates
289
+ // media-type keys above -- this is the one place a status/media-type key becomes a real object key
290
+ // in the exported document, so it cannot simply trust what is already on disk. Presence of a
291
+ // non-null return here IS the seam buildResponses() below acts on: no other logic decides between
292
+ // the two paths.
293
+ function buildPerStatusResponses(op) {
294
+ if (!op.sourceResponses || typeof op.sourceResponses !== 'object' || Array.isArray(op.sourceResponses)) return null;
295
+ const responses = new Map();
296
+ for (const key of Object.keys(op.sourceResponses)) {
297
+ if (!RESPONSE_STATUS_KEY_RE.test(key)) continue;
298
+ const entry = op.sourceResponses[key];
299
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
300
+ // OpenAPI 3.1 requires `description` on every Response Object -- same rule the union path
301
+ // below already follows, source-conditioned here: a real copied description if the source had
302
+ // one, else a stand-in that describes the projection, never the API (same discipline
303
+ // SUCCESS_RESPONSE_DESCRIPTION/ERROR_RESPONSE_DESCRIPTION already established).
304
+ const responseObject = {
305
+ description: typeof entry.description === 'string' && entry.description.length > 0
306
+ ? entry.description
307
+ : PER_STATUS_NO_DESCRIPTION_STANDIN,
149
308
  };
309
+ // Codex review (A8 follow-up): `schemaFrom` is re-checked against the ACTUAL status key it sits
310
+ // under, not just trusted as a string -- the feature-contract schema's `enum` only constrains
311
+ // schemaFrom to one of two values, never that the value agrees with its own status key. Real
312
+ // reconciliation output can never disagree (contracts/openapi.mjs's applyPerStatusResponses only
313
+ // ever sets 'response' under a SUCCESS_STATUS_RE key and 'error' under an ERROR_STATUS_RE/default
314
+ // key), so this only matters for a hand-edited contract file -- S5/persistence-integrity, same
315
+ // posture as the RESPONSE_STATUS_KEY_RE/MEDIA_TYPE_RE re-checks already in this function. A
316
+ // mismatched claim (e.g. "response" under a 4xx key) falls through to the entry's own inline
317
+ // `schema`/`mediaTypes` instead of exporting the wrong union under the wrong status.
318
+ if (entry.schemaFrom === 'response' && SUCCESS_STATUS_RE.test(key) && op.responseSchema) {
319
+ responseObject.content = { [JSON_MEDIA_TYPE]: { schema: op.responseSchema } };
320
+ } else if (entry.schemaFrom === 'error' && (ERROR_STATUS_RE.test(key) || key === DEFAULT_STATUS_KEY) && op.errorSchema) {
321
+ responseObject.content = { [JSON_MEDIA_TYPE]: { schema: op.errorSchema } };
322
+ } else if (entry.schema && typeof entry.schema === 'object' && !Array.isArray(entry.schema)) {
323
+ responseObject.content = { [JSON_MEDIA_TYPE]: { schema: entry.schema } };
324
+ } else if (Array.isArray(entry.mediaTypes) && entry.mediaTypes.length > 0) {
325
+ const content = {};
326
+ for (const mt of entry.mediaTypes) {
327
+ if (MEDIA_TYPE_RE.test(mt)) content[mt] = {};
328
+ }
329
+ if (Object.keys(content).length > 0) responseObject.content = content;
330
+ }
331
+ responses.set(key, responseObject);
150
332
  }
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;
333
+ return responses.size > 0 ? Object.fromEntries(responses) : null;
155
334
  }
156
335
 
157
336
  // `range` (default) uses `2XX`/`default`, both legal per the official 3.1 meta-schema's own
@@ -162,7 +341,15 @@ function buildRequestBody(op) {
162
341
  // one (`$defs.operation` has no `required` array -- also verified by execution), and `responses: {}`
163
342
  // is illegal anyway (`minProperties: 1`). Omitting is both legal and honest; guessing a status
164
343
  // would be neither.
344
+ //
345
+ // A8: buildPerStatusResponses() above is tried FIRST -- when the contract carries the source
346
+ // document's own real per-status data for this operation, that is emitted verbatim instead of
347
+ // collapsing everything into the 2XX/default union below. The union path is otherwise completely
348
+ // unchanged.
165
349
  function buildResponses(op, statusCodes) {
350
+ const perStatus = buildPerStatusResponses(op);
351
+ if (perStatus) return perStatus;
352
+
166
353
  const responses = new Map();
167
354
  if (op.responseSchema) {
168
355
  responses.set(statusCodes === 'literal' ? '200' : '2XX', {
@@ -237,6 +424,17 @@ export function buildOpenApiDocument({ contract, snapshot = null, options = {} }
237
424
  const pathItems = new Map();
238
425
  const claimedRoutes = new Map();
239
426
 
427
+ // A7: computed BEFORE the per-operation loop (unlike A6's original placement, after it) -- the
428
+ // passthrough marker embedded on each operation below needs the contract's own content hash,
429
+ // same key material contractSha256() has always produced.
430
+ const sha256 = contractSha256(contract);
431
+ // A7: tracks, per operationId, whether this export emitted AT LEAST ONE passthrough field for
432
+ // it -- becomes generated.passthrough (machine-readable, always present) and the ONE stderr
433
+ // note bin/bskel.mjs's cmdContractExport prints when coverage is genuinely mixed (see
434
+ // D-openapi-passthrough: mixed coverage can only arise in an explicitly waived `partial`
435
+ // contract -- a `complete` one has 100% coverage by construction).
436
+ const passthroughByOperation = {};
437
+
240
438
  for (const operationId of operationIds) {
241
439
  const op = contract.operations[operationId];
242
440
  const route = String(op.path);
@@ -255,24 +453,61 @@ export function buildOpenApiDocument({ contract, snapshot = null, options = {} }
255
453
  claimedRoutes.set(routeKey, operationId);
256
454
 
257
455
  const operation = { operationId };
258
- const parameters = buildPathParameters(op);
456
+ const parameters = buildOperationParameters(op);
259
457
  if (parameters.length > 0) operation.parameters = parameters;
260
458
  const requestBody = buildRequestBody(op);
261
459
  if (requestBody) operation.requestBody = requestBody;
262
460
  const responses = buildResponses(op, statusCodes);
263
461
  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.
462
+ // A7: `security`/`summary`/`tags` are emitted ONLY when the contract carries a copied
463
+ // source* value for THIS operation -- i.e. a real source document (--openapi-file) stated one
464
+ // for this exact operation. `security: []` is spec-legal (confirmed by executing the
465
+ // meta-schema) AND, when copied, a genuine positive claim FROM THE SOURCE that no
466
+ // authentication is required -- Array.isArray, not a truthy check, so `[]` is correctly
467
+ // treated as present. `op.sourceSecurity` is never emitted when absent; `description` (the
468
+ // operation-level field, not the response-object one) remains deliberately unset -- Phase 2.
469
+ if (Array.isArray(op.sourceSecurity)) operation.security = op.sourceSecurity;
470
+ if (op.sourceSummary) operation.summary = op.sourceSummary;
471
+ if (Array.isArray(op.sourceTags) && op.sourceTags.length > 0) operation.tags = op.sourceTags;
472
+
473
+ // A8: two more clauses -- an operation whose ONLY passthrough is per-status responses or a
474
+ // copied multipart body (no source parameters/security/summary/tags at all) previously got NO
475
+ // marker, reopening the exact self-import hole A7 closed for that one operation. Never arises
476
+ // on the real oracle (148/148 already carry summary+tags+security) but is structurally
477
+ // reachable from a minimal hand-written document declaring only `responses` -- see
478
+ // D-openapi-per-status.
479
+ const hasPassthrough = Boolean(
480
+ (Array.isArray(op.sourceParameters) && op.sourceParameters.length > 0)
481
+ || Array.isArray(op.sourceSecurity)
482
+ || op.sourceSummary
483
+ || (Array.isArray(op.sourceTags) && op.sourceTags.length > 0)
484
+ || (op.sourceResponses && typeof op.sourceResponses === 'object' && Object.keys(op.sourceResponses).length > 0)
485
+ || (op.sourceRequestBody && typeof op.sourceRequestBody === 'object'),
486
+ );
487
+ passthroughByOperation[operationId] = hasPassthrough;
488
+ if (hasPassthrough) {
489
+ // A7: the self-import guard's SECOND key material -- see contracts/openapi.mjs's
490
+ // hasBskelExportMarker(). Proportionate to how real a passthrough-heavy export now looks:
491
+ // stripping only info.x-bskel-generated is no longer sufficient to disarm the guard for
492
+ // any operation carrying this marker.
493
+ operation[BSKEL_PASSTHROUGH_EXTENSION] = { source_sha256: sha256.slice(0, 12) };
494
+ }
269
495
 
270
496
  if (!pathItems.has(route)) pathItems.set(route, new Map());
271
497
  pathItems.get(route).set(verb, operation);
272
498
  }
273
499
 
274
500
  const omissions = collectOmissions(contract);
275
- const sha256 = contractSha256(contract);
501
+ // A7: only schemes actually referenced by at least one copied sourceSecurity requirement --
502
+ // contracts/openapi.mjs's applySecurity already guarantees this set is internally consistent
503
+ // (every scheme name any emitted op.sourceSecurity references IS a key here), so no re-
504
+ // verification is needed at export time -- never a dangling reference by construction.
505
+ const securitySchemes = contract.sourceSecuritySchemes && typeof contract.sourceSecuritySchemes === 'object'
506
+ ? contract.sourceSecuritySchemes
507
+ : null;
508
+ const passthroughValues = Object.values(passthroughByOperation);
509
+ const passthroughMixed = passthroughValues.some(Boolean) && passthroughValues.some((v) => !v);
510
+ const passthroughWithoutCount = passthroughValues.filter((v) => !v).length;
276
511
 
277
512
  const generated = {
278
513
  feature_id: contract.feature_id,
@@ -282,6 +517,10 @@ export function buildOpenApiDocument({ contract, snapshot = null, options = {} }
282
517
  contract_sha256: sha256,
283
518
  status_codes: statusCodes,
284
519
  exported_by: options.exportedBy ?? 'bskel',
520
+ // A7: machine-readable per-operation coverage map -- disclosure, not a new refusal. A
521
+ // `complete` contract has every value `true` by construction (see D-openapi-passthrough);
522
+ // mixed values can only arise in an explicitly waived `partial` contract.
523
+ passthrough: passthroughByOperation,
285
524
  };
286
525
  // Provenance only, and only when this feature actually reconciled against a real document --
287
526
  // says WHICH oracle the paths in here were corrected against, which is the first thing anyone
@@ -310,6 +549,12 @@ export function buildOpenApiDocument({ contract, snapshot = null, options = {} }
310
549
  },
311
550
  paths: Object.fromEntries([...pathItems].map(([route, verbs]) => [route, Object.fromEntries(verbs)])),
312
551
  };
552
+ // A7: emitted in the document's own `components` section, restricted to schemes actually
553
+ // referenced by at least one emitted operation's `security` -- never the document's full
554
+ // catalog, and never a dangling reference (see the comment on `securitySchemes` above).
555
+ if (securitySchemes && Object.keys(securitySchemes).length > 0) {
556
+ document.components = { securitySchemes };
557
+ }
313
558
 
314
559
  return {
315
560
  ok: true,
@@ -319,7 +564,15 @@ export function buildOpenApiDocument({ contract, snapshot = null, options = {} }
319
564
  statusCodes,
320
565
  // True when `literal` actually produced at least one stand-in `200` -- lets the CLI print
321
566
  // 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)),
567
+ // operation or unconditionally. A8: excludes an operation that took the per-status path --
568
+ // its `200` (if any) is a copied real status key, never a bskel-chosen stand-in.
569
+ literalStatusStandIn: statusCodes === 'literal' && Object.values(contract.operations).some((op) => {
570
+ const hasPerStatus = op.sourceResponses && typeof op.sourceResponses === 'object' && Object.keys(op.sourceResponses).length > 0;
571
+ return Boolean(op.responseSchema) && !hasPerStatus;
572
+ }),
573
+ // A7: lets cmdContractExport print its ONE mixed-coverage stderr note only when genuinely
574
+ // warranted -- see D-openapi-passthrough.
575
+ mixedPassthrough: passthroughMixed,
576
+ passthroughWithoutCount,
324
577
  };
325
578
  }