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.
- package/COMMERCIAL-LICENSE-AGREEMENT.md +399 -0
- package/COMMERCIAL-LICENSE.md +46 -0
- package/LICENSE +661 -21
- package/README.md +20 -8
- package/bin/bskel.mjs +32 -1
- package/contracts/completeness.mjs +35 -0
- package/contracts/emit.mjs +114 -4
- package/contracts/export.mjs +298 -45
- package/contracts/openapi.mjs +593 -17
- package/package.json +6 -3
- package/schemas/feature-contract.schema.json +56 -1
package/contracts/openapi.mjs
CHANGED
|
@@ -42,6 +42,17 @@ const SCHEMA_REF_PREFIX = '#/components/schemas/';
|
|
|
42
42
|
// response/error side can have many documented statuses -- MAX_RESPONSES_PER_OPERATION bounds
|
|
43
43
|
// that (real max observed: 9). See D-openapi-response-schema in DECISIONS.md.
|
|
44
44
|
const MAX_RESPONSES_PER_OPERATION = 64;
|
|
45
|
+
// A7: source-backed passthrough caps, same "generous multiple of the real observed max" style as
|
|
46
|
+
// every cap above. Measured against the real Team-IZ-Backend oracle: max 9 parameters on one
|
|
47
|
+
// operation, max 1 security requirement per operation, exactly 1 security scheme document-wide.
|
|
48
|
+
const MAX_PARAMETERS_PER_OPERATION = 64;
|
|
49
|
+
const MAX_SECURITY_REQUIREMENTS_PER_OPERATION = 32;
|
|
50
|
+
const MAX_SECURITY_SCHEMES = 64;
|
|
51
|
+
// A8: per-status responses / non-JSON request media types. No new cap for per-status responses --
|
|
52
|
+
// MAX_RESPONSES_PER_OPERATION above already bounds the same `responses` map (real max observed:
|
|
53
|
+
// 9). MAX_REQUEST_MEDIA_TYPES is new: real max observed on one operation's requestBody.content is
|
|
54
|
+
// 1 (always either application/json alone or multipart/form-data alone in the oracle).
|
|
55
|
+
const MAX_REQUEST_MEDIA_TYPES = 16;
|
|
45
56
|
// A6 (D-openapi-export): widened from `/^2[0-9]{2}$/` and `/^[45][0-9]{2}$/` to also accept
|
|
46
57
|
// OpenAPI's own RANGE keys. These are ordinary in real hand-written documents and legal per the
|
|
47
58
|
// official 3.1 meta-schema, whose `responses` object accepts exactly `^[1-5](?:[0-9]{2}|XX)$` plus
|
|
@@ -51,9 +62,16 @@ const MAX_RESPONSES_PER_OPERATION = 64;
|
|
|
51
62
|
// is (correctly) not a failure, so the loss produced no warning anywhere. This is a general
|
|
52
63
|
// importer capability gain, not round-trip plumbing -- it is what makes such a document readable
|
|
53
64
|
// by `contract emit --openapi-file` at all.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
// Exported (not just module-local) so contracts/export.mjs can re-check a hand-edited contract's
|
|
66
|
+
// `schemaFrom` claim against the ACTUAL status key it sits under, instead of trusting the string --
|
|
67
|
+
// same S5/persistence-integrity "never trust the on-disk contract file" posture RESPONSE_STATUS_KEY_RE/
|
|
68
|
+
// MEDIA_TYPE_RE already follow (Codex review finding, A8 follow-up: a hand-edited
|
|
69
|
+
// `sourceResponses["400"].schemaFrom:"response"` passed schema validation and exported `responseSchema`
|
|
70
|
+
// under HTTP 400 -- the schema's `enum` on schemaFrom checks the value is one of two strings, never that
|
|
71
|
+
// it agrees with the status key it's attached to).
|
|
72
|
+
export const SUCCESS_STATUS_RE = /^2(?:[0-9]{2}|XX)$/;
|
|
73
|
+
export const ERROR_STATUS_RE = /^[45](?:[0-9]{2}|XX)$/;
|
|
74
|
+
export const DEFAULT_STATUS_KEY = 'default';
|
|
57
75
|
|
|
58
76
|
// Same shape convention as the rest of contracts/ -- operationId becomes an object key
|
|
59
77
|
// downstream (contracts/emit.mjs's `operations[operationId]`), so it's whitelisted before it's
|
|
@@ -83,6 +101,33 @@ export const PATH_PREFIX_RE = /^(?:\/[A-Za-z0-9._~%-]+)+$/;
|
|
|
83
101
|
export const COMPONENT_SCHEMA_NAME_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,199}$/;
|
|
84
102
|
export const SCHEMA_PROPERTY_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
|
|
85
103
|
|
|
104
|
+
// A8: an OpenAPI response-object status key becomes an object key downstream, in both this
|
|
105
|
+
// contract's sourceResponses and the exported document's responses -- same prototype-pollution
|
|
106
|
+
// class OPERATION_ID_RE/COMPONENT_SCHEMA_NAME_RE guard against. A real status key is a literal
|
|
107
|
+
// 3-digit code (100-599), a range key (1XX-5XX), or the literal "default" -- nothing else is a
|
|
108
|
+
// legal OpenAPI response status, so anything else is DROPPED (not a failure), mirroring
|
|
109
|
+
// indexOpenApiDocument()'s own "malformed entry, safe to skip" posture.
|
|
110
|
+
export const RESPONSE_STATUS_KEY_RE = /^(?:[1-5](?:[0-9]{2}|XX)|default)$/;
|
|
111
|
+
// A8: a media-type key becomes an object key downstream too (sourceRequestBody, a per-status
|
|
112
|
+
// entry's mediaTypes disclosure, and the exported document itself). A real media type always
|
|
113
|
+
// contains a '/' (type/subtype per RFC 6838) -- that requirement alone structurally excludes
|
|
114
|
+
// '__proto__'/'constructor'/'toString', the same defense COMPONENT_SCHEMA_NAME_RE's leading-letter
|
|
115
|
+
// requirement provides one layer down.
|
|
116
|
+
export const MEDIA_TYPE_RE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/;
|
|
117
|
+
|
|
118
|
+
// A8: the exact stand-in text contracts/export.mjs writes into a per-status Response Object when
|
|
119
|
+
// the source had no description of its own (OpenAPI 3.1 requires one on every Response Object).
|
|
120
|
+
// Declared here, the READING side, for the identical "writer and reader cannot drift apart" reason
|
|
121
|
+
// BSKEL_GENERATED_EXTENSION/BSKEL_PASSTHROUGH_EXTENSION are -- applyPerStatusResponses() below must
|
|
122
|
+
// recognize and skip this exact string on import, or round-tripping an export whose source had no
|
|
123
|
+
// real description would silently launder synthetic filler text into a real contract field,
|
|
124
|
+
// breaking the "export -> re-import reproduces operations exactly" invariant A6/A7 already
|
|
125
|
+
// established. (The pre-A8 union path's own two stand-in descriptions never needed this defense --
|
|
126
|
+
// nothing on the import side ever reads a response object's `description`, only its `content`
|
|
127
|
+
// schema; this field is the first to read description back in, which is what introduces the
|
|
128
|
+
// hazard.)
|
|
129
|
+
export const PER_STATUS_NO_DESCRIPTION_STANDIN = 'The source document documents this status for this operation but gives no description.';
|
|
130
|
+
|
|
86
131
|
// inlineSchema()'s keyword policy: RECURSED keywords are walked into; ASSERTION keywords are
|
|
87
132
|
// copied verbatim (their values are scalars/arrays of scalars, not schema nodes -- nothing to
|
|
88
133
|
// recurse); DROPPED keywords carry no validation meaning and are silently discarded (their
|
|
@@ -93,8 +138,14 @@ export const SCHEMA_PROPERTY_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
|
|
|
93
138
|
// emit a schema WEAKER than the real one, which is worse than emitting no schema at all -- see
|
|
94
139
|
// D-openapi-request-schema in DECISIONS.md.
|
|
95
140
|
const RECURSED_KEYWORDS = Object.freeze(new Set(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf']));
|
|
141
|
+
// A7: `default` added -- annotation-only per 2020-12 (Ajv runs with useDefaults off here, so it's
|
|
142
|
+
// inert for validation either way), but a real, human-authored fact from the source document worth
|
|
143
|
+
// carrying through regardless. Measured across every real request-body and response schema in the
|
|
144
|
+
// oracle: 0 contain `default` anywhere, so this addition provably changes zero bytes of A2/A3
|
|
145
|
+
// output on real data -- the only place it actually fires is parameter schemas (22/253 real
|
|
146
|
+
// parameters use it directly, another 9 via a `$ref`-sibling, see the $ref handling below).
|
|
96
147
|
const COPIED_KEYWORDS = Object.freeze(new Set([
|
|
97
|
-
'type', 'enum', 'const', 'required',
|
|
148
|
+
'type', 'enum', 'const', 'required', 'default',
|
|
98
149
|
'minLength', 'maxLength',
|
|
99
150
|
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
|
|
100
151
|
'minItems', 'maxItems', 'uniqueItems',
|
|
@@ -123,10 +174,35 @@ class InlineFailure extends Error {
|
|
|
123
174
|
// writer and the reader cannot drift apart and silently disarm the guard.
|
|
124
175
|
export const BSKEL_GENERATED_EXTENSION = 'x-bskel-generated';
|
|
125
176
|
|
|
177
|
+
// A7: a SECOND, per-operation marker. A passthrough-heavy export (real query/header parameters, a
|
|
178
|
+
// real bearer-auth requirement, real summaries/tags) reads much closer to the source oracle
|
|
179
|
+
// document than A6's original thin projection did, so the documented escape hatch (stripping
|
|
180
|
+
// `info.x-bskel-generated`) needs to get harder to trigger by accident, proportionate to how real
|
|
181
|
+
// the export now looks. Declared here, alongside BSKEL_GENERATED_EXTENSION, for the identical
|
|
182
|
+
// "writer and reader cannot drift apart" reason -- contracts/export.mjs imports both.
|
|
183
|
+
export const BSKEL_PASSTHROUGH_EXTENSION = 'x-bskel-passthrough';
|
|
184
|
+
|
|
126
185
|
export function hasBskelExportMarker(doc) {
|
|
127
186
|
const info = doc?.info;
|
|
128
|
-
if (typeof info
|
|
129
|
-
|
|
187
|
+
if (typeof info === 'object' && info !== null && !Array.isArray(info) && Object.hasOwn(info, BSKEL_GENERATED_EXTENSION)) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
// A7: OR any operation carries the passthrough marker -- closes the exact gap a passthrough-
|
|
191
|
+
// heavy export would otherwise leave: stripping only `info.x-bskel-generated` used to be
|
|
192
|
+
// sufficient to disarm the guard for ANY export; now it is only sufficient for one that carries
|
|
193
|
+
// no copied parameters/security/summary/tags at all.
|
|
194
|
+
const paths = doc?.paths;
|
|
195
|
+
if (typeof paths !== 'object' || paths === null || Array.isArray(paths)) return false;
|
|
196
|
+
for (const pathItem of Object.values(paths)) {
|
|
197
|
+
if (typeof pathItem !== 'object' || pathItem === null || Array.isArray(pathItem)) continue;
|
|
198
|
+
for (const [methodKey, operation] of Object.entries(pathItem)) {
|
|
199
|
+
if (!HTTP_METHODS.has(methodKey.toLowerCase())) continue; // skip 'parameters'/'summary'/etc path-item-level keys
|
|
200
|
+
if (operation && typeof operation === 'object' && !Array.isArray(operation) && Object.hasOwn(operation, BSKEL_PASSTHROUGH_EXTENSION)) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
130
206
|
}
|
|
131
207
|
|
|
132
208
|
export function normalizeRoute(routePath) {
|
|
@@ -186,17 +262,24 @@ export function indexOpenApiDocument(doc) {
|
|
|
186
262
|
const byOperationId = new Map();
|
|
187
263
|
const byRoute = new Map();
|
|
188
264
|
const componentSchemas = new Map();
|
|
265
|
+
// A7: Map<name, schemeNode> from doc.components.securitySchemes -- same "Map, never a plain
|
|
266
|
+
// object" reasoning as componentSchemas above, same COMPONENT_SCHEMA_NAME_RE whitelist (a
|
|
267
|
+
// security scheme name becomes an object key downstream, in the contract's own root-level
|
|
268
|
+
// sourceSecuritySchemes).
|
|
269
|
+
const securitySchemes = new Map();
|
|
189
270
|
const stats = {
|
|
190
271
|
path_count: 0, operation_count: 0, skipped_path_refs: 0, rejected_operation_ids: 0,
|
|
191
272
|
component_schema_count: 0, rejected_component_schemas: 0,
|
|
273
|
+
security_scheme_count: 0, rejected_security_schemes: 0,
|
|
192
274
|
};
|
|
193
275
|
|
|
194
276
|
const openapiVersion = typeof doc.openapi === 'string' ? doc.openapi : null;
|
|
195
277
|
const schemaDialectSupported = typeof openapiVersion === 'string' && /^3\.1(?:\.|$)/.test(openapiVersion);
|
|
196
278
|
|
|
197
|
-
const
|
|
198
|
-
? doc.components
|
|
279
|
+
const rawComponents = doc.components && typeof doc.components === 'object' && !Array.isArray(doc.components)
|
|
280
|
+
? doc.components
|
|
199
281
|
: null;
|
|
282
|
+
const rawComponentSchemas = rawComponents ? rawComponents.schemas : null;
|
|
200
283
|
if (rawComponentSchemas && typeof rawComponentSchemas === 'object' && !Array.isArray(rawComponentSchemas)) {
|
|
201
284
|
const schemaNames = Object.keys(rawComponentSchemas);
|
|
202
285
|
if (schemaNames.length > MAX_COMPONENT_SCHEMAS) {
|
|
@@ -211,9 +294,24 @@ export function indexOpenApiDocument(doc) {
|
|
|
211
294
|
stats.component_schema_count = componentSchemas.size;
|
|
212
295
|
}
|
|
213
296
|
|
|
297
|
+
const rawSecuritySchemes = rawComponents ? rawComponents.securitySchemes : null;
|
|
298
|
+
if (rawSecuritySchemes && typeof rawSecuritySchemes === 'object' && !Array.isArray(rawSecuritySchemes)) {
|
|
299
|
+
const schemeNames = Object.keys(rawSecuritySchemes);
|
|
300
|
+
if (schemeNames.length > MAX_SECURITY_SCHEMES) {
|
|
301
|
+
return { ok: false, error: `OpenAPI document has ${schemeNames.length} security schemes, exceeds the ${MAX_SECURITY_SCHEMES}-scheme limit` };
|
|
302
|
+
}
|
|
303
|
+
for (const name of schemeNames) {
|
|
304
|
+
const value = rawSecuritySchemes[name];
|
|
305
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue;
|
|
306
|
+
if (!COMPONENT_SCHEMA_NAME_RE.test(name)) { stats.rejected_security_schemes++; continue; }
|
|
307
|
+
securitySchemes.set(name, value);
|
|
308
|
+
}
|
|
309
|
+
stats.security_scheme_count = securitySchemes.size;
|
|
310
|
+
}
|
|
311
|
+
|
|
214
312
|
const paths = doc.paths;
|
|
215
313
|
if (typeof paths !== 'object' || paths === null || Array.isArray(paths)) {
|
|
216
|
-
return { ok: true, byOperationId, byRoute, componentSchemas, stats, servers: [], openapiVersion, schemaDialectSupported };
|
|
314
|
+
return { ok: true, byOperationId, byRoute, componentSchemas, securitySchemes, stats, servers: [], openapiVersion, schemaDialectSupported };
|
|
217
315
|
}
|
|
218
316
|
|
|
219
317
|
const pathKeys = Object.keys(paths);
|
|
@@ -266,7 +364,17 @@ export function indexOpenApiDocument(doc) {
|
|
|
266
364
|
const responses = typeof operation.responses === 'object' && operation.responses !== null && !Array.isArray(operation.responses)
|
|
267
365
|
? operation.responses
|
|
268
366
|
: null;
|
|
269
|
-
|
|
367
|
+
// A7: raw parameters/security/summary/tags retained verbatim, same "no new read, no new
|
|
368
|
+
// size cap" reasoning as requestBody/responses above. `security` is deliberately
|
|
369
|
+
// Array.isArray-checked rather than truthy-checked -- a real, explicit `[]` (11/148 real
|
|
370
|
+
// operations) must be preserved as-is, not collapsed into "absent" the way a falsy check
|
|
371
|
+
// would (an empty array is truthy in JS, so this already works, but the explicit
|
|
372
|
+
// Array.isArray guard is what actually decides "present" vs "absent/malformed").
|
|
373
|
+
const parameters = Array.isArray(operation.parameters) ? operation.parameters : null;
|
|
374
|
+
const security = Array.isArray(operation.security) ? operation.security : null;
|
|
375
|
+
const summary = typeof operation.summary === 'string' ? operation.summary : null;
|
|
376
|
+
const tags = Array.isArray(operation.tags) ? operation.tags : null;
|
|
377
|
+
const entry = { verb, path: routeKey, operationId, requestBody, responses, parameters, security, summary, tags };
|
|
270
378
|
|
|
271
379
|
const routeMatchKey = `${verb} ${normalizedRoute}`;
|
|
272
380
|
const existingRoute = byRoute.get(routeMatchKey);
|
|
@@ -284,7 +392,7 @@ export function indexOpenApiDocument(doc) {
|
|
|
284
392
|
? doc.servers.filter((s) => s && typeof s.url === 'string').map((s) => s.url)
|
|
285
393
|
: [];
|
|
286
394
|
|
|
287
|
-
return { ok: true, byOperationId, byRoute, componentSchemas, stats, servers, openapiVersion, schemaDialectSupported };
|
|
395
|
+
return { ok: true, byOperationId, byRoute, componentSchemas, securitySchemes, stats, servers, openapiVersion, schemaDialectSupported };
|
|
288
396
|
}
|
|
289
397
|
|
|
290
398
|
// `S` (scan path) always starts with "/" (scanners/adapters/java-spring.mjs's joinPath guarantees
|
|
@@ -353,11 +461,14 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
|
|
|
353
461
|
|
|
354
462
|
if (Object.hasOwn(node, '$ref')) {
|
|
355
463
|
// 2020-12 permits siblings alongside $ref (unlike OpenAPI 3.0's restriction), but this
|
|
356
|
-
// module doesn't attempt to MERGE $ref with a sibling assertion
|
|
357
|
-
//
|
|
358
|
-
//
|
|
464
|
+
// module doesn't attempt to MERGE $ref with a sibling assertion, with ONE exception (A7):
|
|
465
|
+
// `default` is a real, human-authored override worth carrying through (the exact real shape
|
|
466
|
+
// `{"$ref": ".../ProjectListSort", "default": "READINESS"}` -- a $ref-typed parameter
|
|
467
|
+
// schema with its own default value, 9 real occurrences). A DROPPED_KEYWORDS sibling (e.g. a
|
|
468
|
+
// documentation-only `description`) is harmless and ignored; anything else would need merge
|
|
469
|
+
// semantics this vertical slice doesn't implement, so it fails closed.
|
|
359
470
|
const siblingKeys = Object.keys(node).filter((k) => k !== '$ref');
|
|
360
|
-
if (siblingKeys.some((k) => !DROPPED_KEYWORDS.has(k))) fail('ref-with-siblings');
|
|
471
|
+
if (siblingKeys.some((k) => !DROPPED_KEYWORDS.has(k) && k !== 'default')) fail('ref-with-siblings');
|
|
361
472
|
const ref = node['$ref'];
|
|
362
473
|
if (typeof ref !== 'string' || !ref.startsWith(SCHEMA_REF_PREFIX)) fail('unsupported-ref');
|
|
363
474
|
const name = ref.slice(SCHEMA_REF_PREFIX.length);
|
|
@@ -368,13 +479,20 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
|
|
|
368
479
|
const target = componentSchemas.get(name);
|
|
369
480
|
if (!target) fail('component-not-found');
|
|
370
481
|
visiting.add(name);
|
|
482
|
+
let resolved;
|
|
371
483
|
try {
|
|
372
|
-
|
|
484
|
+
resolved = walkSchemaNode(target, componentSchemas, depth + 1, visiting, state, limits);
|
|
373
485
|
} finally {
|
|
374
486
|
// Delete-on-exit: a diamond (two sibling properties referencing the SAME component) stays
|
|
375
487
|
// legal and is inlined independently for each -- only a true ancestor-chain cycle fails.
|
|
376
488
|
visiting.delete(name);
|
|
377
489
|
}
|
|
490
|
+
// A7: the sibling `default` (if any) is merged into the RESOLVED schema, not the $ref node
|
|
491
|
+
// itself -- it describes what value applies when this particular use of the component is
|
|
492
|
+
// absent, which is a fact about the reference site, carried onto the schema the reference
|
|
493
|
+
// resolves to.
|
|
494
|
+
if (Object.hasOwn(node, 'default')) resolved.default = node.default;
|
|
495
|
+
return resolved;
|
|
378
496
|
}
|
|
379
497
|
|
|
380
498
|
const out = {};
|
|
@@ -626,6 +744,381 @@ function applyProjectionOutcome(result, projected, stats, kind) {
|
|
|
626
744
|
}
|
|
627
745
|
}
|
|
628
746
|
|
|
747
|
+
// ===== A7: source-backed OpenAPI field passthrough =====
|
|
748
|
+
// D-openapi-passthrough: a field may be copied iff its value exists verbatim in the source
|
|
749
|
+
// document, on the operation object reconciliation already tied to this contract operation, and
|
|
750
|
+
// the only transformation applied is one this module already performs mechanically elsewhere
|
|
751
|
+
// (inlineSchema()'s $ref inlining + keyword whitelist + format:uuid rewrite). Anything requiring a
|
|
752
|
+
// decision about what the API probably does stays omitted. A7 (Phase 1): non-path parameters,
|
|
753
|
+
// security, summary, tags. A8 (D-openapi-per-status, below): per-status responses, non-JSON
|
|
754
|
+
// request media types. Operation-level `description` remains excluded (measured too expensive to
|
|
755
|
+
// default-on -- see D-openapi-per-status).
|
|
756
|
+
|
|
757
|
+
// A7: `in` locations this passthrough may copy. 'path' is deliberately excluded -- path parameters
|
|
758
|
+
// stay contract-derived from the route template + the contract's own name heuristic (see
|
|
759
|
+
// contracts/export.mjs's buildPathParameters), a genuinely separate kind of claim from a copied
|
|
760
|
+
// source parameter, disclosed as its own `path-parameter-schemas` omission entry rather than
|
|
761
|
+
// silently superseded here.
|
|
762
|
+
const PARAMETER_LOCATIONS = Object.freeze(new Set(['query', 'header', 'cookie']));
|
|
763
|
+
|
|
764
|
+
// A7: Parameter Object key policy -- mirrors inlineSchema()'s RECURSED/COPIED/DROPPED/fail-closed
|
|
765
|
+
// shape one level up (a Parameter Object, not a Schema Object). COPIED verbatim (every value here
|
|
766
|
+
// is a scalar, nothing to recurse into); `schema` is RESOLVED through inlineSchema() (handled
|
|
767
|
+
// separately in copyParameter, below); `example`/`examples` are DROPPED (annotation-only, same
|
|
768
|
+
// reasoning DROPPED_KEYWORDS already applies one layer down); a `$ref` parameter (needs
|
|
769
|
+
// components.parameters, which indexOpenApiDocument() does not index), a `content`-keyed parameter
|
|
770
|
+
// (a media-type-keyed alternative to `schema` -- Phase 2 machinery), or any other unrecognized key
|
|
771
|
+
// fails CLOSED for that ONE parameter only, never the whole operation.
|
|
772
|
+
const PARAMETER_COPIED_KEYS = Object.freeze(new Set([
|
|
773
|
+
'required', 'deprecated', 'description', 'style', 'explode', 'allowReserved', 'allowEmptyValue',
|
|
774
|
+
]));
|
|
775
|
+
const PARAMETER_DROPPED_KEYS = Object.freeze(new Set(['example', 'examples']));
|
|
776
|
+
|
|
777
|
+
function safeParamName(raw) {
|
|
778
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) && typeof raw.name === 'string' ? raw.name : null;
|
|
779
|
+
}
|
|
780
|
+
function safeParamIn(raw) {
|
|
781
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) && typeof raw.in === 'string' ? raw.in : null;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Non-path parameters only, source order, deduplicated on (name,in) -- first occurrence wins, same
|
|
785
|
+
// "first occurrence wins" convention indexOpenApiDocument()'s own byOperationId uses. A malformed
|
|
786
|
+
// entry (not an object, or missing name/in as strings) cannot participate in dedup meaningfully --
|
|
787
|
+
// it is still counted and still attempted (and will fail its own copy attempt on its own merits).
|
|
788
|
+
function collectNonPathParameters(rawParameters) {
|
|
789
|
+
if (!Array.isArray(rawParameters)) return [];
|
|
790
|
+
const seen = new Set();
|
|
791
|
+
const out = [];
|
|
792
|
+
for (const p of rawParameters) {
|
|
793
|
+
if (p && typeof p === 'object' && !Array.isArray(p) && p.in === 'path') continue; // stays contract-derived
|
|
794
|
+
const name = safeParamName(p);
|
|
795
|
+
const loc = safeParamIn(p);
|
|
796
|
+
if (name !== null && loc !== null) {
|
|
797
|
+
const key = `${loc}${name}`;
|
|
798
|
+
if (seen.has(key)) continue;
|
|
799
|
+
seen.add(key);
|
|
800
|
+
}
|
|
801
|
+
out.push(p);
|
|
802
|
+
}
|
|
803
|
+
return out;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Copies one raw Parameter Object into the sourceParameters shape, or fails it closed.
|
|
807
|
+
// Returns one of:
|
|
808
|
+
// {ok:false, reason} -- dropped entirely, not copied at all
|
|
809
|
+
// {ok:true, parameter} -- copied cleanly, schema included if present
|
|
810
|
+
// {ok:true, parameter, schemaUnresolvedReason} -- copied, but `schema` could not resolve
|
|
811
|
+
// The middle+last cases both add the parameter to sourceParameters (every OTHER field it carries is
|
|
812
|
+
// real and safe); the last case additionally drives CONTRACT_OPENAPI_PARAMETERS_UNRESOLVED, exactly
|
|
813
|
+
// the "found but couldn't project" distinction applyRequestBodySchema already draws for a body.
|
|
814
|
+
function copyParameter(raw, componentSchemas) {
|
|
815
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, reason: 'not-a-parameter-object' };
|
|
816
|
+
if (Object.hasOwn(raw, '$ref')) return { ok: false, reason: 'ref-parameter' };
|
|
817
|
+
if (Object.hasOwn(raw, 'content')) return { ok: false, reason: 'content-parameter' };
|
|
818
|
+
const name = raw.name;
|
|
819
|
+
if (typeof name !== 'string' || name.length === 0) return { ok: false, reason: 'missing-name' };
|
|
820
|
+
const loc = raw.in;
|
|
821
|
+
if (!PARAMETER_LOCATIONS.has(loc)) return { ok: false, reason: 'unsupported-location' };
|
|
822
|
+
|
|
823
|
+
const parameter = { name, in: loc };
|
|
824
|
+
for (const key of Object.keys(raw)) {
|
|
825
|
+
if (key === 'name' || key === 'in' || key === 'schema') continue; // handled explicitly
|
|
826
|
+
if (PARAMETER_DROPPED_KEYS.has(key)) continue;
|
|
827
|
+
if (PARAMETER_COPIED_KEYS.has(key)) { parameter[key] = raw[key]; continue; }
|
|
828
|
+
return { ok: false, reason: `unsupported-keyword:${key}` };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (Object.hasOwn(raw, 'schema')) {
|
|
832
|
+
const resolved = inlineSchema(raw.schema, componentSchemas);
|
|
833
|
+
if (resolved.ok) {
|
|
834
|
+
parameter.schema = resolved.schema;
|
|
835
|
+
} else {
|
|
836
|
+
return { ok: true, parameter, schemaUnresolvedReason: resolved.reason };
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return { ok: true, parameter };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// A7: parameters ride the schema-bearing gate (schemaProjectionEnabled) -- a `schema` inside a
|
|
843
|
+
// parameter goes through inlineSchema() exactly like a request/response body does, and a 3.0
|
|
844
|
+
// document's `exclusiveMinimum`/`nullable` mean something different under the 2020-12 dialect
|
|
845
|
+
// Ajv2020 speaks, the same reasoning that already disables A2/A3 for a whole document. Sets, on
|
|
846
|
+
// `result`: parametersTotal/parametersCleanCount (reconciliation-internal bookkeeping consumed only
|
|
847
|
+
// by snapshotFromReconciliation, never copied into the contract itself), parametersSkippedDialect,
|
|
848
|
+
// sourceParameters (omitted when empty), parametersUnresolved (omitted when empty).
|
|
849
|
+
function applyParameters(result, docEntry, index, stats, schemaProjectionEnabled) {
|
|
850
|
+
const candidates = collectNonPathParameters(docEntry.parameters);
|
|
851
|
+
if (candidates.length === 0) {
|
|
852
|
+
stats.parameters_none++;
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (!schemaProjectionEnabled) {
|
|
856
|
+
result.parametersSkippedDialect = true;
|
|
857
|
+
stats.parameters_skipped_dialect++;
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const copied = [];
|
|
862
|
+
const unresolved = [];
|
|
863
|
+
let cleanCount = 0;
|
|
864
|
+
|
|
865
|
+
if (candidates.length > MAX_PARAMETERS_PER_OPERATION) {
|
|
866
|
+
// Exceeding the cap fails the WHOLE parameter set closed for this operation, never the whole
|
|
867
|
+
// reconciliation -- same "fail this field, not the operation kind" posture every cap in this
|
|
868
|
+
// module already takes.
|
|
869
|
+
for (const p of candidates) {
|
|
870
|
+
unresolved.push({ name: safeParamName(p), in: safeParamIn(p), reason: 'too-many-parameters' });
|
|
871
|
+
}
|
|
872
|
+
} else {
|
|
873
|
+
for (const raw of candidates) {
|
|
874
|
+
const outcome = copyParameter(raw, index.componentSchemas);
|
|
875
|
+
if (!outcome.ok) {
|
|
876
|
+
unresolved.push({ name: safeParamName(raw), in: safeParamIn(raw), reason: outcome.reason });
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
copied.push(outcome.parameter);
|
|
880
|
+
if (outcome.schemaUnresolvedReason) {
|
|
881
|
+
unresolved.push({ name: outcome.parameter.name, in: outcome.parameter.in, reason: outcome.schemaUnresolvedReason });
|
|
882
|
+
} else {
|
|
883
|
+
cleanCount++;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
result.parametersTotal = candidates.length;
|
|
889
|
+
result.parametersCleanCount = cleanCount;
|
|
890
|
+
if (copied.length > 0) result.sourceParameters = copied;
|
|
891
|
+
if (unresolved.length > 0) result.parametersUnresolved = unresolved;
|
|
892
|
+
|
|
893
|
+
if (cleanCount === candidates.length) stats.parameters_copied++;
|
|
894
|
+
else stats.parameters_unresolved++;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// A7: security is dialect-independent -- a Security Requirement Object means the same thing under
|
|
898
|
+
// 3.0 and 3.1, so this is copied regardless of schemaProjectionEnabled (a deliberate asymmetry with
|
|
899
|
+
// applyParameters above, not an oversight -- see D-openapi-passthrough). All-or-nothing per
|
|
900
|
+
// operation (unlike parameters' per-item fail-closed): a requirement naming a scheme that could not
|
|
901
|
+
// be resolved means the WHOLE security value is dropped for that operation, never a dangling
|
|
902
|
+
// reference -- verified by executing the real 3.1 meta-schema that it cannot itself catch this (a
|
|
903
|
+
// `security` requirement naming an undeclared scheme validates `true`). `referencedSchemeNames` is
|
|
904
|
+
// a Set shared across the whole reconciliation -- accumulates every scheme name any operation's
|
|
905
|
+
// COPIED security actually referenced, which becomes the contract-root `sourceSecuritySchemes`.
|
|
906
|
+
function applySecurity(result, docEntry, index, stats, referencedSchemeNames) {
|
|
907
|
+
const security = docEntry.security;
|
|
908
|
+
if (security === null) {
|
|
909
|
+
stats.security_none++;
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (security.length > MAX_SECURITY_REQUIREMENTS_PER_OPERATION) {
|
|
913
|
+
result.securityUnresolvedReason = 'too-many-requirements';
|
|
914
|
+
stats.security_unresolved++;
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const namesInThisOperation = [];
|
|
918
|
+
for (const requirement of security) {
|
|
919
|
+
if (!requirement || typeof requirement !== 'object' || Array.isArray(requirement)) {
|
|
920
|
+
result.securityUnresolvedReason = 'malformed-requirement';
|
|
921
|
+
stats.security_unresolved++;
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
for (const schemeName of Object.keys(requirement)) {
|
|
925
|
+
if (!Object.hasOwn(requirement, schemeName)) continue; // Object.keys is already own-only; defensive
|
|
926
|
+
if (!index.securitySchemes.has(schemeName)) {
|
|
927
|
+
result.securityUnresolvedReason = 'unknown-scheme';
|
|
928
|
+
stats.security_unresolved++;
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
namesInThisOperation.push(schemeName);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
result.sourceSecurity = security;
|
|
935
|
+
for (const name of namesInThisOperation) referencedSchemeNames.add(name);
|
|
936
|
+
if (security.length === 0) stats.security_public++;
|
|
937
|
+
else stats.security_copied++;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// A7: summary/tags are dialect-independent, same reasoning as applySecurity above -- copied
|
|
941
|
+
// verbatim, never synthesized from operationId or module names (same discipline A6's own omitted-
|
|
942
|
+
// summaries rule established, now source-conditioned instead of always-omitted).
|
|
943
|
+
function applySummaryAndTags(result, docEntry, stats) {
|
|
944
|
+
if (typeof docEntry.summary === 'string' && docEntry.summary.length > 0) {
|
|
945
|
+
result.sourceSummary = docEntry.summary;
|
|
946
|
+
stats.summary_copied++;
|
|
947
|
+
}
|
|
948
|
+
if (Array.isArray(docEntry.tags) && docEntry.tags.length > 0) {
|
|
949
|
+
const tags = docEntry.tags.filter((t) => typeof t === 'string');
|
|
950
|
+
if (tags.length > 0) {
|
|
951
|
+
result.sourceTags = tags;
|
|
952
|
+
stats.tags_copied++;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// A8: per-status responses -- additive to (never replacing) the responseSchema/errorSchema union
|
|
958
|
+
// projection above; contracts/validate.mjs is untouched by this item, see D-openapi-per-status.
|
|
959
|
+
// Gated on schemaProjectionEnabled, same reasoning as applyParameters -- a JSON Schema resolved
|
|
960
|
+
// under a 3.0 document's dialect means something different under 2020-12, and per-status
|
|
961
|
+
// responses resolve schemas via the exact same inlineSchema() path parameters/bodies already do.
|
|
962
|
+
//
|
|
963
|
+
// Written only when BOTH projectResponseSchemas() outcomes for this operation are resolved-or-none
|
|
964
|
+
// (never unresolved) -- if either bucket failed to resolve, this silently and correctly skips,
|
|
965
|
+
// falling back to the existing union-only export path; the failure is already recorded via A3's
|
|
966
|
+
// own responseSchemaUnresolvedReason/errorSchemaUnresolvedReason, nothing here duplicates it.
|
|
967
|
+
//
|
|
968
|
+
// schemaFrom is provable, not guessed: projectResponseSchemas() already deduplicated every status
|
|
969
|
+
// in its bucket down to `sources` distinct resolved shapes -- if sources===1, EVERY status in that
|
|
970
|
+
// bucket with a JSON schema resolved to THAT one shape, by construction, so this function can point
|
|
971
|
+
// at it instead of re-resolving. When sources>1 (never observed on real data, but structurally
|
|
972
|
+
// possible) or the status sits outside both buckets (a 1xx/3xx key), the status's own schema is
|
|
973
|
+
// resolved individually into an inline `schema` instead.
|
|
974
|
+
function applyPerStatusResponses(result, docEntry, componentSchemas, stats, schemaProjectionEnabled) {
|
|
975
|
+
if (!schemaProjectionEnabled) {
|
|
976
|
+
result.perStatusResponsesSkippedDialect = true;
|
|
977
|
+
stats.per_status_skipped_dialect++;
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
const responses = docEntry.responses;
|
|
981
|
+
if (!responses || typeof responses !== 'object' || Array.isArray(responses) || Object.keys(responses).length === 0) {
|
|
982
|
+
stats.per_status_none++;
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (result.responseSchemaUnresolvedReason || result.errorSchemaUnresolvedReason) {
|
|
986
|
+
result.perStatusResponsesSkippedUnresolved = true;
|
|
987
|
+
stats.per_status_skipped_unresolved++;
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const perStatus = {};
|
|
992
|
+
let anyEntry = false;
|
|
993
|
+
for (const key of Object.keys(responses)) {
|
|
994
|
+
if (!RESPONSE_STATUS_KEY_RE.test(key)) continue; // not a legal status key -- dropped, not a failure
|
|
995
|
+
const resp = responses[key];
|
|
996
|
+
if (typeof resp !== 'object' || resp === null || Array.isArray(resp)) continue;
|
|
997
|
+
// A8 follow-up (Codex review): a Response Object `$ref` (`components.responses.<Name>`, legal
|
|
998
|
+
// per the official 3.1 meta-schema's `response-or-reference`) is not resolved here -- 0 real
|
|
999
|
+
// occurrences against the Team-IZ-Backend oracle (694 response objects, 0 $ref), named rather
|
|
1000
|
+
// than built, same "don't build for zero real cases" discipline as non-json-response-schemas/
|
|
1001
|
+
// response-headers below. Skipping is the fail-closed choice: falling through with
|
|
1002
|
+
// resp.description/resp.content both undefined would produce a description-only entry carrying
|
|
1003
|
+
// the synthetic PER_STATUS_NO_DESCRIPTION_STANDIN as if the source truly documented this status
|
|
1004
|
+
// with no description -- false. A referenced response is simply omitted for this status.
|
|
1005
|
+
if (typeof resp.$ref === 'string') continue;
|
|
1006
|
+
|
|
1007
|
+
const entry = {};
|
|
1008
|
+
if (typeof resp.description === 'string' && resp.description.length > 0 && resp.description !== PER_STATUS_NO_DESCRIPTION_STANDIN) {
|
|
1009
|
+
entry.description = resp.description;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const content = resp.content;
|
|
1013
|
+
if (content && typeof content === 'object' && !Array.isArray(content)) {
|
|
1014
|
+
if (Object.hasOwn(content, JSON_MEDIA_TYPE)) {
|
|
1015
|
+
const mediaEntry = content[JSON_MEDIA_TYPE];
|
|
1016
|
+
const schemaNode = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
|
|
1017
|
+
if (schemaNode && typeof schemaNode === 'object' && !Array.isArray(schemaNode)) {
|
|
1018
|
+
if (SUCCESS_STATUS_RE.test(key) && result.responseSchema && result.responseSchemaSources === 1) {
|
|
1019
|
+
entry.schemaFrom = 'response';
|
|
1020
|
+
} else if ((ERROR_STATUS_RE.test(key) || key === DEFAULT_STATUS_KEY) && result.errorSchema && result.errorSchemaSources === 1) {
|
|
1021
|
+
entry.schemaFrom = 'error';
|
|
1022
|
+
} else {
|
|
1023
|
+
const resolved = inlineSchema(schemaNode, componentSchemas);
|
|
1024
|
+
// unresolved here just leaves `schema` absent -- `description` alone (if any) stays
|
|
1025
|
+
// valid, same "copied without schema" posture copyParameter() already takes.
|
|
1026
|
+
if (resolved.ok) entry.schema = resolved.schema;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
} else {
|
|
1030
|
+
const mediaTypes = Object.keys(content).filter((mt) => MEDIA_TYPE_RE.test(mt));
|
|
1031
|
+
if (mediaTypes.length > 0) entry.mediaTypes = mediaTypes;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
perStatus[key] = entry;
|
|
1036
|
+
anyEntry = true;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
if (!anyEntry) {
|
|
1040
|
+
stats.per_status_none++;
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
result.sourceResponses = perStatus;
|
|
1044
|
+
stats.per_status_copied++;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// A8: non-JSON request media types (multipart/form-data in the real oracle) -- additive to A2's
|
|
1048
|
+
// requestBodySchema, never overlapping it: `content` here never contains application/json (that
|
|
1049
|
+
// stays A2's field, the one representation of that one fact -- expressed as a schema invariant in
|
|
1050
|
+
// schemas/feature-contract.schema.json too). Gated on schemaProjectionEnabled for the same reason
|
|
1051
|
+
// as applyPerStatusResponses above -- a media-type schema resolves through the same inlineSchema()
|
|
1052
|
+
// path.
|
|
1053
|
+
function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schemaProjectionEnabled) {
|
|
1054
|
+
if (!schemaProjectionEnabled) {
|
|
1055
|
+
result.requestMediaTypesSkippedDialect = true;
|
|
1056
|
+
stats.request_media_types_skipped_dialect++;
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
const requestBody = docEntry.requestBody;
|
|
1060
|
+
if (!requestBody || typeof requestBody !== 'object' || Array.isArray(requestBody) || Object.hasOwn(requestBody, '$ref')) {
|
|
1061
|
+
stats.request_media_types_none++;
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const content = requestBody.content;
|
|
1065
|
+
if (typeof content !== 'object' || content === null || Array.isArray(content)) {
|
|
1066
|
+
stats.request_media_types_none++;
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const mediaTypeKeys = Object.keys(content).filter((mt) => mt !== JSON_MEDIA_TYPE && MEDIA_TYPE_RE.test(mt));
|
|
1070
|
+
if (mediaTypeKeys.length === 0) {
|
|
1071
|
+
stats.request_media_types_none++;
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (mediaTypeKeys.length > MAX_REQUEST_MEDIA_TYPES) {
|
|
1075
|
+
// Exceeding the cap fails the WHOLE media-type set closed for this operation, same "fail this
|
|
1076
|
+
// field, not the operation kind" posture every cap in this module already takes.
|
|
1077
|
+
result.requestMediaTypesUnresolvedReason = 'too-many-media-types';
|
|
1078
|
+
stats.request_media_types_unresolved++;
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const copiedContent = {};
|
|
1083
|
+
let cleanCount = 0;
|
|
1084
|
+
for (const mt of mediaTypeKeys) {
|
|
1085
|
+
const mediaEntry = content[mt];
|
|
1086
|
+
const entry = {};
|
|
1087
|
+
const schemaNode = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
|
|
1088
|
+
if (schemaNode && typeof schemaNode === 'object' && !Array.isArray(schemaNode)) {
|
|
1089
|
+
const resolved = inlineSchema(schemaNode, componentSchemas);
|
|
1090
|
+
if (resolved.ok) { entry.schema = resolved.schema; cleanCount++; }
|
|
1091
|
+
} else {
|
|
1092
|
+
cleanCount++; // no schema declared for this media type at all is not a failure -- same
|
|
1093
|
+
// "nothing to project" posture applyRequestBodySchema already takes.
|
|
1094
|
+
}
|
|
1095
|
+
copiedContent[mt] = entry;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
result.sourceRequestBody = { content: copiedContent };
|
|
1099
|
+
if (requestBody.required === true) result.sourceRequestBody.required = true;
|
|
1100
|
+
if (cleanCount === mediaTypeKeys.length) {
|
|
1101
|
+
stats.request_media_types_copied++;
|
|
1102
|
+
} else {
|
|
1103
|
+
result.requestMediaTypesUnresolvedReason = 'schema-unresolved';
|
|
1104
|
+
stats.request_media_types_unresolved++;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// A7: the single entry point called from reconcileModule()'s two matched/adopted call sites --
|
|
1109
|
+
// exactly the placement A2/A3's own helpers already occupy, which IS the refusal mechanism for
|
|
1110
|
+
// every other resolution kind (drift/missing/ambiguous/unresolved never reach this function at
|
|
1111
|
+
// all, see reconcileModule below).
|
|
1112
|
+
function applyPassthrough(result, docEntry, index, stats, schemaProjectionEnabled, referencedSchemeNames) {
|
|
1113
|
+
applyParameters(result, docEntry, index, stats, schemaProjectionEnabled);
|
|
1114
|
+
applySecurity(result, docEntry, index, stats, referencedSchemeNames);
|
|
1115
|
+
applySummaryAndTags(result, docEntry, stats);
|
|
1116
|
+
// A8: same matched/adopted-only placement as the three calls above -- this IS the refusal
|
|
1117
|
+
// mechanism for every other resolution kind, extended unchanged for the two new fields.
|
|
1118
|
+
applyPerStatusResponses(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled);
|
|
1119
|
+
applyRequestMediaTypes(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
629
1122
|
// The core reconciliation, pure (no I/O). `module` is a scanReport related_modules entry (as
|
|
630
1123
|
// selected by contracts/emit.mjs's selectModule -- caller's responsibility to pass the SAME
|
|
631
1124
|
// selection buildContract() will use, so endpointKey(ci,ei) lines up). `pathPrefix`, if given
|
|
@@ -660,7 +1153,22 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
|
|
|
660
1153
|
// A3: response (2xx) / error (4xx/5xx) counters, same stable-shape reasoning.
|
|
661
1154
|
response_schema_resolved: 0, response_schema_unresolved: 0, response_schema_none: 0, response_schema_skipped_media_type: 0,
|
|
662
1155
|
error_schema_resolved: 0, error_schema_unresolved: 0, error_schema_none: 0, error_schema_skipped_media_type: 0,
|
|
1156
|
+
// A7: source-backed passthrough counters -- parameters/security are per-OPERATION tallies
|
|
1157
|
+
// (an operation with a mix of resolved/unresolved parameters counts once, under
|
|
1158
|
+
// parameters_unresolved, matching the "partial:M-of-N" snapshot decision), summary/tags are
|
|
1159
|
+
// simple presence counts.
|
|
1160
|
+
parameters_copied: 0, parameters_unresolved: 0, parameters_none: 0, parameters_skipped_dialect: 0,
|
|
1161
|
+
security_copied: 0, security_public: 0, security_unresolved: 0, security_none: 0,
|
|
1162
|
+
summary_copied: 0, tags_copied: 0,
|
|
1163
|
+
// A8: per-status responses / request media types counters, same per-operation-tally shape as
|
|
1164
|
+
// the A7 counters above.
|
|
1165
|
+
per_status_copied: 0, per_status_skipped_unresolved: 0, per_status_none: 0, per_status_skipped_dialect: 0,
|
|
1166
|
+
request_media_types_copied: 0, request_media_types_unresolved: 0, request_media_types_none: 0, request_media_types_skipped_dialect: 0,
|
|
663
1167
|
};
|
|
1168
|
+
// A7: accumulates every security-scheme name any operation's COPIED security requirement
|
|
1169
|
+
// actually referenced, across the WHOLE module -- becomes the contract-root sourceSecuritySchemes
|
|
1170
|
+
// (only schemes actually used, never the document's full componentSchemas-style catalog).
|
|
1171
|
+
const referencedSecuritySchemeNames = new Set();
|
|
664
1172
|
// A2: an OpenAPI 3.0 document's `exclusiveMinimum`/`nullable` mean something different under
|
|
665
1173
|
// JSON Schema 2020-12 (the dialect Ajv2020 speaks) -- rather than silently misinterpreting
|
|
666
1174
|
// those, schema projection is disabled for the WHOLE document, once, here -- not per-operation
|
|
@@ -701,6 +1209,11 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
|
|
|
701
1209
|
applyRequestBodySchema(result, docEntry, index.componentSchemas, stats);
|
|
702
1210
|
applyResponseSchemas(result, docEntry, index.componentSchemas, stats);
|
|
703
1211
|
}
|
|
1212
|
+
// A7: same matched/adopted-only placement -- this IS the refusal mechanism for
|
|
1213
|
+
// every other resolution kind. Called unconditionally (not gated on
|
|
1214
|
+
// schemaProjection.enabled): parameters gate internally (schema-bearing);
|
|
1215
|
+
// security/summary/tags are dialect-independent and always attempted.
|
|
1216
|
+
applyPassthrough(result, docEntry, index, stats, schemaProjection.enabled, referencedSecuritySchemeNames);
|
|
704
1217
|
} else {
|
|
705
1218
|
result = {
|
|
706
1219
|
kind: 'drift', reason: 'path',
|
|
@@ -729,6 +1242,7 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
|
|
|
729
1242
|
applyRequestBodySchema(result, hits[0], index.componentSchemas, stats);
|
|
730
1243
|
applyResponseSchemas(result, hits[0], index.componentSchemas, stats);
|
|
731
1244
|
}
|
|
1245
|
+
applyPassthrough(result, hits[0], index, stats, schemaProjection.enabled, referencedSecuritySchemeNames);
|
|
732
1246
|
} else if (hits.length === 1) {
|
|
733
1247
|
// A single route match, but the document itself never gave that operation an
|
|
734
1248
|
// operationId -- nothing to route by, so this can't become an addressable
|
|
@@ -749,7 +1263,16 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
|
|
|
749
1263
|
}
|
|
750
1264
|
}
|
|
751
1265
|
|
|
752
|
-
|
|
1266
|
+
// A7: resolve the accumulated scheme names against index.securitySchemes -- only names that
|
|
1267
|
+
// actually made it into at least one operation's COPIED sourceSecurity end up here, so this Map
|
|
1268
|
+
// is exactly what buildContract() should attach as the contract-root sourceSecuritySchemes.
|
|
1269
|
+
const sourceSecuritySchemes = new Map();
|
|
1270
|
+
for (const name of referencedSecuritySchemeNames) {
|
|
1271
|
+
const scheme = index.securitySchemes.get(name);
|
|
1272
|
+
if (scheme) sourceSecuritySchemes.set(name, scheme);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
return { byEndpoint, prefix, stats, schemaProjection, sourceSecuritySchemes };
|
|
753
1276
|
}
|
|
754
1277
|
|
|
755
1278
|
// Convenience entry point: load + index + reconcile in one call, propagating the first failure.
|
|
@@ -790,6 +1313,8 @@ export function buildReconciliation({ filePath, module, pathPrefix = null }) {
|
|
|
790
1313
|
rejected_operation_ids: indexed.stats.rejected_operation_ids,
|
|
791
1314
|
component_schema_count: indexed.stats.component_schema_count,
|
|
792
1315
|
rejected_component_schemas: indexed.stats.rejected_component_schemas,
|
|
1316
|
+
security_scheme_count: indexed.stats.security_scheme_count,
|
|
1317
|
+
rejected_security_schemes: indexed.stats.rejected_security_schemes,
|
|
793
1318
|
openapi_version: indexed.openapiVersion,
|
|
794
1319
|
servers: indexed.servers,
|
|
795
1320
|
},
|
|
@@ -797,9 +1322,50 @@ export function buildReconciliation({ filePath, module, pathPrefix = null }) {
|
|
|
797
1322
|
prefix: recon.prefix,
|
|
798
1323
|
stats: recon.stats,
|
|
799
1324
|
schemaProjection: recon.schemaProjection,
|
|
1325
|
+
// A7: only schemes actually referenced by at least one copied sourceSecurity requirement --
|
|
1326
|
+
// contracts/emit.mjs attaches this at the contract root when non-empty.
|
|
1327
|
+
sourceSecuritySchemes: recon.sourceSecuritySchemes,
|
|
800
1328
|
};
|
|
801
1329
|
}
|
|
802
1330
|
|
|
1331
|
+
// A7: decision-only audit-trail strings for the four passthrough fields, exactly parallel to
|
|
1332
|
+
// request_body_schema/response_schema/error_schema above -- the snapshot records the DECISION, not
|
|
1333
|
+
// the payload (the payload lives in the contract file, already covered by contract_hash).
|
|
1334
|
+
function parametersDecision(result) {
|
|
1335
|
+
if (result.parametersSkippedDialect) return 'skipped:dialect';
|
|
1336
|
+
if (!result.parametersTotal) return 'none';
|
|
1337
|
+
return result.parametersCleanCount === result.parametersTotal
|
|
1338
|
+
? `copied:${result.parametersTotal}`
|
|
1339
|
+
: `partial:${result.parametersCleanCount}-of-${result.parametersTotal}`;
|
|
1340
|
+
}
|
|
1341
|
+
function securityDecision(result) {
|
|
1342
|
+
if (result.sourceSecurity) return result.sourceSecurity.length === 0 ? 'copied:public' : `copied:${result.sourceSecurity.length}`;
|
|
1343
|
+
if (result.securityUnresolvedReason) return `unresolved:${result.securityUnresolvedReason}`;
|
|
1344
|
+
return 'none';
|
|
1345
|
+
}
|
|
1346
|
+
function summaryDecision(result) {
|
|
1347
|
+
return result.sourceSummary ? 'copied' : 'none';
|
|
1348
|
+
}
|
|
1349
|
+
function tagsDecision(result) {
|
|
1350
|
+
return result.sourceTags ? `copied:${result.sourceTags.length}` : 'none';
|
|
1351
|
+
}
|
|
1352
|
+
// A8: same decision-only audit-trail shape, for the two new passthrough fields.
|
|
1353
|
+
function perStatusResponsesDecision(result) {
|
|
1354
|
+
if (result.perStatusResponsesSkippedDialect) return 'skipped:dialect';
|
|
1355
|
+
if (result.perStatusResponsesSkippedUnresolved) return 'skipped:unresolved';
|
|
1356
|
+
if (result.sourceResponses) return `copied:${Object.keys(result.sourceResponses).length}`;
|
|
1357
|
+
return 'none';
|
|
1358
|
+
}
|
|
1359
|
+
function requestMediaTypesDecision(result) {
|
|
1360
|
+
if (result.requestMediaTypesSkippedDialect) return 'skipped:dialect';
|
|
1361
|
+
if (result.sourceRequestBody) {
|
|
1362
|
+
const count = Object.keys(result.sourceRequestBody.content).length;
|
|
1363
|
+
return result.requestMediaTypesUnresolvedReason ? `partial:${count}` : `copied:${count}`;
|
|
1364
|
+
}
|
|
1365
|
+
if (result.requestMediaTypesUnresolvedReason) return `unresolved:${result.requestMediaTypesUnresolvedReason}`;
|
|
1366
|
+
return 'none';
|
|
1367
|
+
}
|
|
1368
|
+
|
|
803
1369
|
// `sourceFile`: {file, outsideRepo} precomputed by the caller (bin/bskel.mjs knows the repo
|
|
804
1370
|
// root; this module deliberately doesn't) -- keeps machine-specific absolute paths out of a
|
|
805
1371
|
// committed artifact when the OpenAPI file lives outside the repo.
|
|
@@ -831,6 +1397,14 @@ export function snapshotFromReconciliation(reconciliation, { featureId, sourceFi
|
|
|
831
1397
|
: result.errorSchemaUnresolvedReason
|
|
832
1398
|
? `unresolved:${result.errorSchemaUnresolvedReason}`
|
|
833
1399
|
: 'none',
|
|
1400
|
+
// A7: same decision-only audit trail, for the four passthrough fields.
|
|
1401
|
+
parameters: parametersDecision(result),
|
|
1402
|
+
security: securityDecision(result),
|
|
1403
|
+
summary: summaryDecision(result),
|
|
1404
|
+
tags: tagsDecision(result),
|
|
1405
|
+
// A8: same decision-only audit trail, for the two new passthrough fields.
|
|
1406
|
+
per_status_responses: perStatusResponsesDecision(result),
|
|
1407
|
+
request_media_types: requestMediaTypesDecision(result),
|
|
834
1408
|
};
|
|
835
1409
|
}
|
|
836
1410
|
}
|
|
@@ -849,6 +1423,8 @@ export function snapshotFromReconciliation(reconciliation, { featureId, sourceFi
|
|
|
849
1423
|
skipped_path_refs: reconciliation.document.skipped_path_refs,
|
|
850
1424
|
component_schema_count: reconciliation.document.component_schema_count,
|
|
851
1425
|
rejected_component_schemas: reconciliation.document.rejected_component_schemas,
|
|
1426
|
+
security_scheme_count: reconciliation.document.security_scheme_count,
|
|
1427
|
+
rejected_security_schemes: reconciliation.document.rejected_security_schemes,
|
|
852
1428
|
openapi_version: reconciliation.document.openapi_version,
|
|
853
1429
|
servers: reconciliation.document.servers,
|
|
854
1430
|
},
|