backend-skeleton 1.0.0-beta.2 → 1.0.0-beta.4
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/README.md +12 -2
- package/bin/bskel.mjs +260 -11
- package/contracts/completeness.mjs +27 -2
- package/contracts/emit.mjs +68 -9
- package/contracts/export.mjs +94 -25
- package/contracts/openapi.mjs +307 -40
- package/handles/audit.mjs +83 -0
- package/handles/codec.mjs +11 -5
- package/handles/providers/java-spring/emit.mjs +9 -2
- package/handles/providers/java-spring/plan.mjs +20 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +13 -5
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +54 -20
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +17 -1
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +5 -0
- package/handles/providers/java-spring.mjs +2 -2
- package/handles/providers/python-fastapi/emit.mjs +5 -1
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +9 -1
- package/handles/providers/python-fastapi/templates/router.py.tmpl +43 -19
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +10 -1
- package/lib/cli.mjs +51 -3
- package/lib/handles-manifest.mjs +10 -4
- package/lib/repo.mjs +56 -0
- package/package.json +1 -1
- package/scanners/adapters/_express-shared.mjs +17 -12
- package/scanners/adapters/generic-grep.mjs +5 -1
- package/scanners/adapters/java-spring.mjs +60 -14
- package/scanners/adapters/javascript-express.mjs +5 -1
- package/scanners/adapters/python-fastapi.mjs +17 -14
- package/scanners/adapters/typescript-express.mjs +6 -1
- package/scanners/registry.mjs +5 -2
- package/scanners/text-util.mjs +25 -0
- package/schemas/adapter.schema.json +6 -2
- package/schemas/contract-resolution.schema.json +6 -1
- package/schemas/feature-contract.schema.json +10 -1
- package/schemas/handles-plan.schema.json +1 -0
- package/stack/bootstrap/db-up.sh +52 -0
- package/stack/bootstrap/docker-compose.postgres.yml +18 -0
- package/stack/catalog/postgres-dev-db.yml +55 -0
package/contracts/openapi.mjs
CHANGED
|
@@ -53,6 +53,20 @@ const MAX_SECURITY_SCHEMES = 64;
|
|
|
53
53
|
// 9). MAX_REQUEST_MEDIA_TYPES is new: real max observed on one operation's requestBody.content is
|
|
54
54
|
// 1 (always either application/json alone or multipart/form-data alone in the oracle).
|
|
55
55
|
const MAX_REQUEST_MEDIA_TYPES = 16;
|
|
56
|
+
// A10: operation-level `description`, same "generous multiple of the real observed max" style as
|
|
57
|
+
// every cap above -- real max observed on the Team-IZ-Backend oracle (148 operations, 146 carry a
|
|
58
|
+
// non-empty description) is 9,083 (`.length`, UTF-16 code units, same measure MAX_PATTERN_LENGTH
|
|
59
|
+
// already uses -- NOT a UTF-8 byte count, which runs higher for this oracle's real multi-byte
|
|
60
|
+
// Korean text: 13,758 bytes for the same longest description).
|
|
61
|
+
const MAX_DESCRIPTION_LENGTH = 40000;
|
|
62
|
+
// A11: a FIELD-level `example` value, inside a schema this whole file resolves -- unlike
|
|
63
|
+
// MAX_DESCRIPTION_LENGTH's single operation-level string, `example` is an arbitrary JSON value
|
|
64
|
+
// (string/number/array/object all occur for real, see D-openapi-field-docs), so the cap applies to
|
|
65
|
+
// its serialized (`JSON.stringify(value).length`) size, not `.length` directly. Real max observed
|
|
66
|
+
// on the oracle: 70. A generously round bound, not a tight multiple, since a legitimately useful
|
|
67
|
+
// example (e.g. a full sample response object) could reasonably run longer than any single real
|
|
68
|
+
// value happened to here.
|
|
69
|
+
const MAX_EXAMPLE_LENGTH = 2000;
|
|
56
70
|
// A6 (D-openapi-export): widened from `/^2[0-9]{2}$/` and `/^[45][0-9]{2}$/` to also accept
|
|
57
71
|
// OpenAPI's own RANGE keys. These are ordinary in real hand-written documents and legal per the
|
|
58
72
|
// official 3.1 meta-schema, whose `responses` object accepts exactly `^[1-5](?:[0-9]{2}|XX)$` plus
|
|
@@ -130,12 +144,16 @@ export const PER_STATUS_NO_DESCRIPTION_STANDIN = 'The source document documents
|
|
|
130
144
|
|
|
131
145
|
// inlineSchema()'s keyword policy: RECURSED keywords are walked into; ASSERTION keywords are
|
|
132
146
|
// copied verbatim (their values are scalars/arrays of scalars, not schema nodes -- nothing to
|
|
133
|
-
// recurse);
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
147
|
+
// recurse); DOCUMENTATION keywords (A11) are copied verbatim ONLY when opted in
|
|
148
|
+
// (`includeFieldDocs`), dropped otherwise -- unlike an ASSERTION keyword, dropping one never
|
|
149
|
+
// changes what a schema VALIDATES, only how well-documented it is, so there is no fail-closed
|
|
150
|
+
// concern either way; DROPPED keywords carry no validation meaning AND have zero real occurrences
|
|
151
|
+
// on the oracle (measured, not assumed -- see D-openapi-field-docs), so they are unconditionally
|
|
152
|
+
// discarded regardless of any flag; anything else fails that schema closed. The FORMAT set is
|
|
153
|
+
// checked separately (see inlineSchema's format handling) since `uuid` gets rewritten rather than
|
|
154
|
+
// either copied or dropped. A missing-and-therefore-fail-closed keyword is deliberate: silently
|
|
155
|
+
// dropping an assertion (e.g. an unrecognized `pattern`-like keyword) would emit a schema WEAKER
|
|
156
|
+
// than the real one, which is worse than emitting no schema at all -- see
|
|
139
157
|
// D-openapi-request-schema in DECISIONS.md.
|
|
140
158
|
const RECURSED_KEYWORDS = Object.freeze(new Set(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf']));
|
|
141
159
|
// A7: `default` added -- annotation-only per 2020-12 (Ajv runs with useDefaults off here, so it's
|
|
@@ -151,7 +169,107 @@ const COPIED_KEYWORDS = Object.freeze(new Set([
|
|
|
151
169
|
'minItems', 'maxItems', 'uniqueItems',
|
|
152
170
|
'minProperties', 'maxProperties',
|
|
153
171
|
]));
|
|
154
|
-
|
|
172
|
+
// A11 (D-openapi-field-docs): `description`/`example` measured real and heavily used at the FIELD
|
|
173
|
+
// level (3,982 / 2,077 occurrences across the oracle's request/response/parameter schemas,
|
|
174
|
+
// 520,527 / 32,708 real bytes) -- moved out of DROPPED_KEYWORDS into their own conditionally-
|
|
175
|
+
// copied set. `title`/`examples`(plural)/`externalDocs`/`xml`/`deprecated` stay unconditionally
|
|
176
|
+
// dropped: 0 real occurrences for every one of them (measured, not assumed), so building any
|
|
177
|
+
// copy path for them would violate this project's own "don't build for zero real cases"
|
|
178
|
+
// discipline -- named here, not built, a permanent gap like A8's `non-json-response-schemas`/
|
|
179
|
+
// `response-headers`.
|
|
180
|
+
const DOCUMENTATION_KEYWORDS = Object.freeze(new Set(['description', 'example']));
|
|
181
|
+
const DROPPED_KEYWORDS = Object.freeze(new Set(['title', 'examples', 'externalDocs', 'xml', 'deprecated']));
|
|
182
|
+
|
|
183
|
+
// D-unsupported-annotation-warning: 0 real occurrences on the ONE oracle this whole module's
|
|
184
|
+
// caps/keyword sets were measured against does not mean 0 occurrences everywhere -- a genuinely
|
|
185
|
+
// different real-world document could use any of DROPPED_KEYWORDS, and silently dropping them
|
|
186
|
+
// with no signal at all is a real honesty gap (see D-contract-history/D-gate-export's own backlog
|
|
187
|
+
// for the broader "self-identified weaknesses" context this closes one instance of).
|
|
188
|
+
//
|
|
189
|
+
// Deliberately walks ONLY genuine Schema Object structure (via RECURSED_KEYWORDS, the exact same
|
|
190
|
+
// `properties`/`items`/`additionalProperties`/`oneOf`/`anyOf`/`allOf` set inlineSchema() itself
|
|
191
|
+
// recurses through) -- NOT a blanket "every key anywhere in the document" scan. That distinction
|
|
192
|
+
// is load-bearing, not cosmetic: several of DROPPED_KEYWORDS' names collide with REAL, unrelated
|
|
193
|
+
// OpenAPI concepts that live outside a Schema Object entirely -- an Operation Object's own
|
|
194
|
+
// `deprecated` (marks a whole ENDPOINT deprecated) and a Parameter Object's own `deprecated`, both
|
|
195
|
+
// legitimate 3.1 fields with nothing to do with inlineSchema()'s keyword handling. A blanket walk
|
|
196
|
+
// would misreport those as "an unsupported schema keyword found," which is false. This function
|
|
197
|
+
// only descends from confirmed schema roots (a `$ref`-or-inline `schema` under `content.<media>`
|
|
198
|
+
// or `parameters[].schema`, or a named entry in `components.schemas`), the same roots
|
|
199
|
+
// inlineSchema() itself is ever called on.
|
|
200
|
+
//
|
|
201
|
+
// NOT routed through walkSchemaNode()'s fail-closed machinery -- it must never throw on a shape
|
|
202
|
+
// inlineSchema() itself would reject, since its only job is presence detection, not validation.
|
|
203
|
+
// Bounded for free by loadOpenApiDocument()'s own MAX_DOCUMENT_BYTES check upstream.
|
|
204
|
+
function collectUnsupportedAnnotationKeys(node, found, seen) {
|
|
205
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node) || seen.has(node)) return;
|
|
206
|
+
seen.add(node);
|
|
207
|
+
for (const key of Object.keys(node)) {
|
|
208
|
+
if (DROPPED_KEYWORDS.has(key)) found.add(key);
|
|
209
|
+
if (RECURSED_KEYWORDS.has(key)) {
|
|
210
|
+
const value = node[key];
|
|
211
|
+
// `properties` is a field-NAME -> schema map (its VALUES are schemas, its keys are not
|
|
212
|
+
// schema keywords at all) -- a real bug caught live before merge: recursing into the
|
|
213
|
+
// map object itself, instead of `Object.values(value)`, silently walked past every
|
|
214
|
+
// property's actual schema and found nothing beneath `properties` ever. `items`/
|
|
215
|
+
// `additionalProperties` ARE schemas directly; `oneOf`/`anyOf`/`allOf` are arrays of
|
|
216
|
+
// schemas, already handled by the branch below.
|
|
217
|
+
if (key === 'properties' && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
218
|
+
for (const propSchema of Object.values(value)) collectUnsupportedAnnotationKeys(propSchema, found, seen);
|
|
219
|
+
} else if (Array.isArray(value)) {
|
|
220
|
+
for (const item of value) collectUnsupportedAnnotationKeys(item, found, seen);
|
|
221
|
+
} else {
|
|
222
|
+
collectUnsupportedAnnotationKeys(value, found, seen);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function collectSchemaRootsFromMediaTypes(content, roots) {
|
|
229
|
+
if (!content || typeof content !== 'object' || Array.isArray(content)) return;
|
|
230
|
+
for (const mediaEntry of Object.values(content)) {
|
|
231
|
+
const schema = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
|
|
232
|
+
if (schema && typeof schema === 'object' && !Array.isArray(schema)) roots.push(schema);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Every real schema root a document can offer `inlineSchema()`: named `components.schemas`
|
|
237
|
+
// entries, every operation's requestBody/response content schemas, and every operation's
|
|
238
|
+
// parameter schemas -- deliberately NOT `info`/`servers`/`tags`/security schemes, none of which
|
|
239
|
+
// are ever schema-shaped.
|
|
240
|
+
export function findUnsupportedAnnotations(doc) {
|
|
241
|
+
const roots = [];
|
|
242
|
+
const schemas = doc.components?.schemas;
|
|
243
|
+
if (schemas && typeof schemas === 'object' && !Array.isArray(schemas)) roots.push(...Object.values(schemas));
|
|
244
|
+
|
|
245
|
+
const paths = doc.paths;
|
|
246
|
+
if (paths && typeof paths === 'object' && !Array.isArray(paths)) {
|
|
247
|
+
for (const item of Object.values(paths)) {
|
|
248
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
|
249
|
+
for (const [verbOrKey, operation] of Object.entries(item)) {
|
|
250
|
+
if (!HTTP_METHODS.has(verbOrKey) || !operation || typeof operation !== 'object' || Array.isArray(operation)) continue;
|
|
251
|
+
collectSchemaRootsFromMediaTypes(operation.requestBody?.content, roots);
|
|
252
|
+
if (operation.responses && typeof operation.responses === 'object' && !Array.isArray(operation.responses)) {
|
|
253
|
+
for (const resp of Object.values(operation.responses)) {
|
|
254
|
+
collectSchemaRootsFromMediaTypes(resp?.content, roots);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (Array.isArray(operation.parameters)) {
|
|
258
|
+
for (const p of operation.parameters) {
|
|
259
|
+
if (p && typeof p === 'object' && !Array.isArray(p) && p.schema && typeof p.schema === 'object' && !Array.isArray(p.schema)) {
|
|
260
|
+
roots.push(p.schema);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const found = new Set();
|
|
269
|
+
const seen = new Set();
|
|
270
|
+
for (const root of roots) collectUnsupportedAnnotationKeys(root, found, seen);
|
|
271
|
+
return [...found].sort();
|
|
272
|
+
}
|
|
155
273
|
// Real Team-IZ-Backend format-value histogram (request-body-reachable schemas only): uuid(20),
|
|
156
274
|
// int32(10), email(7), date(10), date-time(3), int64(2). `uuid` is handled separately (rewritten
|
|
157
275
|
// to BARE_UUID_PATTERN, see inlineSchema) -- not in this set, since it never survives as `format`.
|
|
@@ -374,7 +492,11 @@ export function indexOpenApiDocument(doc) {
|
|
|
374
492
|
const security = Array.isArray(operation.security) ? operation.security : null;
|
|
375
493
|
const summary = typeof operation.summary === 'string' ? operation.summary : null;
|
|
376
494
|
const tags = Array.isArray(operation.tags) ? operation.tags : null;
|
|
377
|
-
|
|
495
|
+
// A10: same "raw, no size cap at index time" reasoning as summary above -- MAX_DESCRIPTION_
|
|
496
|
+
// LENGTH is enforced only at applyDescription() (the point of actually copying it into the
|
|
497
|
+
// contract), matching where every other length/count cap in this file is enforced.
|
|
498
|
+
const description = typeof operation.description === 'string' ? operation.description : null;
|
|
499
|
+
const entry = { verb, path: routeKey, operationId, requestBody, responses, parameters, security, summary, tags, description };
|
|
378
500
|
|
|
379
501
|
const routeMatchKey = `${verb} ${normalizedRoute}`;
|
|
380
502
|
const existingRoute = byRoute.get(routeMatchKey);
|
|
@@ -438,6 +560,9 @@ export function inlineSchema(node, componentSchemas, opts = {}) {
|
|
|
438
560
|
maxDepth: opts.maxDepth ?? MAX_SCHEMA_DEPTH,
|
|
439
561
|
maxNodes: opts.maxNodes ?? MAX_SCHEMA_NODES,
|
|
440
562
|
maxPatternLength: opts.maxPatternLength ?? MAX_PATTERN_LENGTH,
|
|
563
|
+
// A11: opt-in only (default false, matching every prior call site's existing behavior
|
|
564
|
+
// byte-for-byte when the caller doesn't pass it) -- see D-openapi-field-docs.
|
|
565
|
+
includeFieldDocs: opts.includeFieldDocs ?? false,
|
|
441
566
|
};
|
|
442
567
|
const state = { nodes: 0 };
|
|
443
568
|
try {
|
|
@@ -464,11 +589,14 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
|
|
|
464
589
|
// module doesn't attempt to MERGE $ref with a sibling assertion, with ONE exception (A7):
|
|
465
590
|
// `default` is a real, human-authored override worth carrying through (the exact real shape
|
|
466
591
|
// `{"$ref": ".../ProjectListSort", "default": "READINESS"}` -- a $ref-typed parameter
|
|
467
|
-
// schema with its own default value, 9 real occurrences). A DROPPED_KEYWORDS
|
|
468
|
-
// documentation-only `description`) is harmless and
|
|
469
|
-
//
|
|
592
|
+
// schema with its own default value, 9 real occurrences). A DROPPED_KEYWORDS or
|
|
593
|
+
// DOCUMENTATION_KEYWORDS sibling (e.g. a documentation-only `description`) is harmless and
|
|
594
|
+
// ignored -- NOT merged onto the resolved schema even when includeFieldDocs is on (A11: 0
|
|
595
|
+
// real occurrences of a $ref carrying a sibling description/example, measured directly, so
|
|
596
|
+
// there is no real case to build merge semantics for, unlike `default`'s 9); anything else
|
|
597
|
+
// would need merge semantics this vertical slice doesn't implement, so it fails closed.
|
|
470
598
|
const siblingKeys = Object.keys(node).filter((k) => k !== '$ref');
|
|
471
|
-
if (siblingKeys.some((k) => !DROPPED_KEYWORDS.has(k) && k !== 'default')) fail('ref-with-siblings');
|
|
599
|
+
if (siblingKeys.some((k) => !DROPPED_KEYWORDS.has(k) && !DOCUMENTATION_KEYWORDS.has(k) && k !== 'default')) fail('ref-with-siblings');
|
|
472
600
|
const ref = node['$ref'];
|
|
473
601
|
if (typeof ref !== 'string' || !ref.startsWith(SCHEMA_REF_PREFIX)) fail('unsupported-ref');
|
|
474
602
|
const name = ref.slice(SCHEMA_REF_PREFIX.length);
|
|
@@ -516,6 +644,32 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
|
|
|
516
644
|
if (key === '$ref' || key === 'format') continue; // format already handled above
|
|
517
645
|
if (DROPPED_KEYWORDS.has(key)) continue;
|
|
518
646
|
|
|
647
|
+
// A11: description/example are DROPPED (same as before this item) unless includeFieldDocs is
|
|
648
|
+
// on -- when it is, copy verbatim IF the value passes a defensive length check, else drop
|
|
649
|
+
// (silently, same as if the flag were off for this one field) rather than failing the whole
|
|
650
|
+
// schema closed. Unlike an ASSERTION keyword's fail-closed policy, dropping an annotation
|
|
651
|
+
// NEVER changes what the schema validates -- only how well-documented it is -- so there is no
|
|
652
|
+
// correctness reason to fail the operation over one oversized documentation string, and a
|
|
653
|
+
// per-FIELD warning here would be unusably noisy (a single schema can carry dozens of these,
|
|
654
|
+
// unlike A10's one-per-operation description). Real data never exercises this path (measured
|
|
655
|
+
// max: 3,148 for description, 70 for example -- both far under their caps), so this is a
|
|
656
|
+
// defensive bound against a hostile/malformed --openapi-file, not an expected real branch.
|
|
657
|
+
if (DOCUMENTATION_KEYWORDS.has(key)) {
|
|
658
|
+
if (!limits.includeFieldDocs) continue;
|
|
659
|
+
if (key === 'description') {
|
|
660
|
+
if (typeof node.description === 'string' && node.description.length <= MAX_DESCRIPTION_LENGTH) {
|
|
661
|
+
out.description = node.description;
|
|
662
|
+
}
|
|
663
|
+
} else if (key === 'example') {
|
|
664
|
+
let serialized;
|
|
665
|
+
try { serialized = JSON.stringify(node.example); } catch { serialized = null; }
|
|
666
|
+
if (serialized !== undefined && serialized !== null && serialized.length <= MAX_EXAMPLE_LENGTH) {
|
|
667
|
+
out.example = node.example;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
|
|
519
673
|
if (key === 'pattern') {
|
|
520
674
|
// Two patterns can't be expressed without allOf, which this slice doesn't attempt to
|
|
521
675
|
// synthesize -- a node with BOTH format:'uuid' and an explicit pattern fails closed
|
|
@@ -597,7 +751,7 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
|
|
|
597
751
|
// let alone body shape. `docEntry` is the OpenAPI-side entry (from byOperationId or byRoute) whose
|
|
598
752
|
// `.requestBody` indexOpenApiDocument() retained. Never treats "nothing to project" as a failure --
|
|
599
753
|
// only an actual unresolvable schema increments schema_unresolved / sets schemaUnresolvedReason.
|
|
600
|
-
function applyRequestBodySchema(result, docEntry, componentSchemas, stats) {
|
|
754
|
+
function applyRequestBodySchema(result, docEntry, componentSchemas, stats, includeFieldDocs) {
|
|
601
755
|
const requestBody = docEntry.requestBody;
|
|
602
756
|
if (!requestBody || Object.hasOwn(requestBody, '$ref')) {
|
|
603
757
|
stats.schema_none++;
|
|
@@ -614,7 +768,7 @@ function applyRequestBodySchema(result, docEntry, componentSchemas, stats) {
|
|
|
614
768
|
stats.schema_none++;
|
|
615
769
|
return;
|
|
616
770
|
}
|
|
617
|
-
const resolved = inlineSchema(schemaNode, componentSchemas);
|
|
771
|
+
const resolved = inlineSchema(schemaNode, componentSchemas, { includeFieldDocs });
|
|
618
772
|
if (resolved.ok) {
|
|
619
773
|
result.requestBodySchema = resolved.schema;
|
|
620
774
|
result.requestBodyRequired = requestBody.required === true;
|
|
@@ -666,7 +820,7 @@ function canonicalJson(value) {
|
|
|
666
820
|
// nothing it had before (nothing read `default` at all until now); one whose `default` describes an
|
|
667
821
|
// error -- the overwhelmingly common case, and the only case `bskel contract export` itself emits --
|
|
668
822
|
// gains a real error schema it previously dropped silently.
|
|
669
|
-
function projectResponseSchemas(responses, statusRe, componentSchemas, { includeDefault = false } = {}) {
|
|
823
|
+
function projectResponseSchemas(responses, statusRe, componentSchemas, { includeDefault = false, includeFieldDocs = false } = {}) {
|
|
670
824
|
if (!responses) return { outcome: 'none' };
|
|
671
825
|
const statusKeys = Object.keys(responses);
|
|
672
826
|
if (statusKeys.length > MAX_RESPONSES_PER_OPERATION) {
|
|
@@ -693,9 +847,14 @@ function projectResponseSchemas(responses, statusRe, componentSchemas, { include
|
|
|
693
847
|
return { outcome: sawContentWithoutJson ? 'skipped-media-type' : 'none' };
|
|
694
848
|
}
|
|
695
849
|
|
|
850
|
+
// A11: includeFieldDocs can make two previously-identical-looking resolved schemas turn out
|
|
851
|
+
// distinct (different field-level description/example), which correctly increases `sources` --
|
|
852
|
+
// see D-openapi-field-docs for why this is a self-consistent consequence of being more precise
|
|
853
|
+
// about equality, not a bug, and the real measurement confirming it never actually happens on
|
|
854
|
+
// the Team-IZ-Backend oracle.
|
|
696
855
|
const resolvedByCanonical = new Map();
|
|
697
856
|
for (const node of rawNodesByKey.values()) {
|
|
698
|
-
const resolved = inlineSchema(node, componentSchemas);
|
|
857
|
+
const resolved = inlineSchema(node, componentSchemas, { includeFieldDocs });
|
|
699
858
|
if (!resolved.ok) return { outcome: 'unresolved', reason: resolved.reason };
|
|
700
859
|
const canonicalKey = canonicalJson(resolved.schema);
|
|
701
860
|
if (!resolvedByCanonical.has(canonicalKey)) resolvedByCanonical.set(canonicalKey, resolved.schema);
|
|
@@ -719,10 +878,10 @@ function projectResponseSchemas(responses, statusRe, componentSchemas, { include
|
|
|
719
878
|
// schemaProjection.enabled guard). Fields are set ONLY when resolved -- omitted, not null/false,
|
|
720
879
|
// so an operation with nothing to project stays byte-identical to pre-A3 output (same discipline
|
|
721
880
|
// as A2's requestBodySchema).
|
|
722
|
-
function applyResponseSchemas(result, docEntry, componentSchemas, stats) {
|
|
723
|
-
applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, SUCCESS_STATUS_RE, componentSchemas), stats, 'response');
|
|
881
|
+
function applyResponseSchemas(result, docEntry, componentSchemas, stats, includeFieldDocs) {
|
|
882
|
+
applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, SUCCESS_STATUS_RE, componentSchemas, { includeFieldDocs }), stats, 'response');
|
|
724
883
|
// A6: `default` contributes to the ERROR side only -- see projectResponseSchemas' own comment.
|
|
725
|
-
applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, ERROR_STATUS_RE, componentSchemas, { includeDefault: true }), stats, 'error');
|
|
884
|
+
applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, ERROR_STATUS_RE, componentSchemas, { includeDefault: true, includeFieldDocs }), stats, 'error');
|
|
726
885
|
}
|
|
727
886
|
|
|
728
887
|
function applyProjectionOutcome(result, projected, stats, kind) {
|
|
@@ -811,7 +970,7 @@ function collectNonPathParameters(rawParameters) {
|
|
|
811
970
|
// The middle+last cases both add the parameter to sourceParameters (every OTHER field it carries is
|
|
812
971
|
// real and safe); the last case additionally drives CONTRACT_OPENAPI_PARAMETERS_UNRESOLVED, exactly
|
|
813
972
|
// the "found but couldn't project" distinction applyRequestBodySchema already draws for a body.
|
|
814
|
-
function copyParameter(raw, componentSchemas) {
|
|
973
|
+
function copyParameter(raw, componentSchemas, includeFieldDocs) {
|
|
815
974
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, reason: 'not-a-parameter-object' };
|
|
816
975
|
if (Object.hasOwn(raw, '$ref')) return { ok: false, reason: 'ref-parameter' };
|
|
817
976
|
if (Object.hasOwn(raw, 'content')) return { ok: false, reason: 'content-parameter' };
|
|
@@ -829,7 +988,7 @@ function copyParameter(raw, componentSchemas) {
|
|
|
829
988
|
}
|
|
830
989
|
|
|
831
990
|
if (Object.hasOwn(raw, 'schema')) {
|
|
832
|
-
const resolved = inlineSchema(raw.schema, componentSchemas);
|
|
991
|
+
const resolved = inlineSchema(raw.schema, componentSchemas, { includeFieldDocs });
|
|
833
992
|
if (resolved.ok) {
|
|
834
993
|
parameter.schema = resolved.schema;
|
|
835
994
|
} else {
|
|
@@ -846,7 +1005,7 @@ function copyParameter(raw, componentSchemas) {
|
|
|
846
1005
|
// `result`: parametersTotal/parametersCleanCount (reconciliation-internal bookkeeping consumed only
|
|
847
1006
|
// by snapshotFromReconciliation, never copied into the contract itself), parametersSkippedDialect,
|
|
848
1007
|
// sourceParameters (omitted when empty), parametersUnresolved (omitted when empty).
|
|
849
|
-
function applyParameters(result, docEntry, index, stats, schemaProjectionEnabled) {
|
|
1008
|
+
function applyParameters(result, docEntry, index, stats, schemaProjectionEnabled, includeFieldDocs) {
|
|
850
1009
|
const candidates = collectNonPathParameters(docEntry.parameters);
|
|
851
1010
|
if (candidates.length === 0) {
|
|
852
1011
|
stats.parameters_none++;
|
|
@@ -871,7 +1030,7 @@ function applyParameters(result, docEntry, index, stats, schemaProjectionEnabled
|
|
|
871
1030
|
}
|
|
872
1031
|
} else {
|
|
873
1032
|
for (const raw of candidates) {
|
|
874
|
-
const outcome = copyParameter(raw, index.componentSchemas);
|
|
1033
|
+
const outcome = copyParameter(raw, index.componentSchemas, includeFieldDocs);
|
|
875
1034
|
if (!outcome.ok) {
|
|
876
1035
|
unresolved.push({ name: safeParamName(raw), in: safeParamIn(raw), reason: outcome.reason });
|
|
877
1036
|
continue;
|
|
@@ -954,6 +1113,34 @@ function applySummaryAndTags(result, docEntry, stats) {
|
|
|
954
1113
|
}
|
|
955
1114
|
}
|
|
956
1115
|
|
|
1116
|
+
// A10 (D-openapi-description): the one A7/A8/A9 sibling that is NOT default-on -- gated behind
|
|
1117
|
+
// `includeDescriptions`, opt-in only, because the measured cost (2,442.7 bytes/operation average
|
|
1118
|
+
// across the real oracle, re-confirmed exactly at this item's own implementation) is larger than
|
|
1119
|
+
// every other field this whole passthrough effort copies COMBINED. Copied verbatim, no
|
|
1120
|
+
// transformation (unlike a schema, there is no keyword whitelist to apply to a plain string) --
|
|
1121
|
+
// fails closed only on length, via MAX_DESCRIPTION_LENGTH, the same defensive posture every other
|
|
1122
|
+
// unbounded-size field in this file has (same defensive-cap class as D-security-1/D-security-2) --
|
|
1123
|
+
// protecting the contract file/gate-token hashing cost from a malformed or hostile --openapi-file,
|
|
1124
|
+
// not a normal document.
|
|
1125
|
+
function applyDescription(result, docEntry, stats, includeDescriptions) {
|
|
1126
|
+
if (!includeDescriptions) {
|
|
1127
|
+
stats.description_skipped_flag++;
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const raw = docEntry.description;
|
|
1131
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
1132
|
+
stats.description_none++;
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (raw.length > MAX_DESCRIPTION_LENGTH) {
|
|
1136
|
+
result.descriptionUnresolvedReason = 'too-long';
|
|
1137
|
+
stats.description_unresolved++;
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
result.sourceDescription = raw;
|
|
1141
|
+
stats.description_copied++;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
957
1144
|
// A8: per-status responses -- additive to (never replacing) the responseSchema/errorSchema union
|
|
958
1145
|
// projection above; contracts/validate.mjs is untouched by this item, see D-openapi-per-status.
|
|
959
1146
|
// Gated on schemaProjectionEnabled, same reasoning as applyParameters -- a JSON Schema resolved
|
|
@@ -971,7 +1158,7 @@ function applySummaryAndTags(result, docEntry, stats) {
|
|
|
971
1158
|
// at it instead of re-resolving. When sources>1 (never observed on real data, but structurally
|
|
972
1159
|
// possible) or the status sits outside both buckets (a 1xx/3xx key), the status's own schema is
|
|
973
1160
|
// resolved individually into an inline `schema` instead.
|
|
974
|
-
function applyPerStatusResponses(result, docEntry, componentSchemas, stats, schemaProjectionEnabled) {
|
|
1161
|
+
function applyPerStatusResponses(result, docEntry, componentSchemas, stats, schemaProjectionEnabled, includeFieldDocs) {
|
|
975
1162
|
if (!schemaProjectionEnabled) {
|
|
976
1163
|
result.perStatusResponsesSkippedDialect = true;
|
|
977
1164
|
stats.per_status_skipped_dialect++;
|
|
@@ -1020,7 +1207,7 @@ function applyPerStatusResponses(result, docEntry, componentSchemas, stats, sche
|
|
|
1020
1207
|
} else if ((ERROR_STATUS_RE.test(key) || key === DEFAULT_STATUS_KEY) && result.errorSchema && result.errorSchemaSources === 1) {
|
|
1021
1208
|
entry.schemaFrom = 'error';
|
|
1022
1209
|
} else {
|
|
1023
|
-
const resolved = inlineSchema(schemaNode, componentSchemas);
|
|
1210
|
+
const resolved = inlineSchema(schemaNode, componentSchemas, { includeFieldDocs });
|
|
1024
1211
|
// unresolved here just leaves `schema` absent -- `description` alone (if any) stays
|
|
1025
1212
|
// valid, same "copied without schema" posture copyParameter() already takes.
|
|
1026
1213
|
if (resolved.ok) entry.schema = resolved.schema;
|
|
@@ -1050,7 +1237,7 @@ function applyPerStatusResponses(result, docEntry, componentSchemas, stats, sche
|
|
|
1050
1237
|
// schemas/feature-contract.schema.json too). Gated on schemaProjectionEnabled for the same reason
|
|
1051
1238
|
// as applyPerStatusResponses above -- a media-type schema resolves through the same inlineSchema()
|
|
1052
1239
|
// path.
|
|
1053
|
-
function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schemaProjectionEnabled) {
|
|
1240
|
+
function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schemaProjectionEnabled, includeFieldDocs) {
|
|
1054
1241
|
if (!schemaProjectionEnabled) {
|
|
1055
1242
|
result.requestMediaTypesSkippedDialect = true;
|
|
1056
1243
|
stats.request_media_types_skipped_dialect++;
|
|
@@ -1086,7 +1273,7 @@ function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schem
|
|
|
1086
1273
|
const entry = {};
|
|
1087
1274
|
const schemaNode = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
|
|
1088
1275
|
if (schemaNode && typeof schemaNode === 'object' && !Array.isArray(schemaNode)) {
|
|
1089
|
-
const resolved = inlineSchema(schemaNode, componentSchemas);
|
|
1276
|
+
const resolved = inlineSchema(schemaNode, componentSchemas, { includeFieldDocs });
|
|
1090
1277
|
if (resolved.ok) { entry.schema = resolved.schema; cleanCount++; }
|
|
1091
1278
|
} else {
|
|
1092
1279
|
cleanCount++; // no schema declared for this media type at all is not a failure -- same
|
|
@@ -1105,18 +1292,73 @@ function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schem
|
|
|
1105
1292
|
}
|
|
1106
1293
|
}
|
|
1107
1294
|
|
|
1295
|
+
// A9 (D-openapi-path-params): the real fix for A7's own disclosed `batchRequestId` finding --
|
|
1296
|
+
// contracts/emit.mjs's pathParamsSchema() names a path segment by NAME ONLY (`/id$/i` ->
|
|
1297
|
+
// BARE_UUID_PATTERN), a heuristic that is provably wrong for at least one real path parameter
|
|
1298
|
+
// (`batchRequestId`, a plain string despite the "Id" suffix). Unlike A7's own parameters (query/
|
|
1299
|
+
// header/cookie, which stay ADDITIVE alongside pathParams' own separate story), this one REPLACES
|
|
1300
|
+
// the heuristic's guess in place for any segment the source document can answer -- the same
|
|
1301
|
+
// "positive information overrides a guess" principle A8's `hasSourceMediaTypeInfo` already
|
|
1302
|
+
// established, applied here to path-param TYPE instead of request media type.
|
|
1303
|
+
//
|
|
1304
|
+
// Returns a Map (never a plain object -- this is `--openapi-file`-sourced, untrusted data keyed by
|
|
1305
|
+
// parameter NAME, the exact class RESPONSE_STATUS_KEY_RE/MEDIA_TYPE_RE exist to defend against
|
|
1306
|
+
// elsewhere in this file; a Map sidesteps prototype pollution entirely rather than needing a third
|
|
1307
|
+
// whitelist regex) from resolved path-parameter name to its inlined schema, stashed transiently on
|
|
1308
|
+
// `result` for contracts/emit.mjs's pathParamsSchema() call to consult -- never itself persisted to
|
|
1309
|
+
// the contract; only the corrected `pathParams` (and, when at least one segment still falls back to
|
|
1310
|
+
// the heuristic, `pathParamsHeuristic`) are.
|
|
1311
|
+
function applyPathParameterSchemas(result, docEntry, componentSchemas, stats, schemaProjectionEnabled, includeFieldDocs) {
|
|
1312
|
+
const rawParameters = Array.isArray(docEntry.parameters) ? docEntry.parameters : [];
|
|
1313
|
+
const pathParams = rawParameters.filter((p) => p && typeof p === 'object' && !Array.isArray(p) && p.in === 'path');
|
|
1314
|
+
if (pathParams.length === 0) {
|
|
1315
|
+
stats.path_params_none++;
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
if (!schemaProjectionEnabled) {
|
|
1319
|
+
result.pathParamsSkippedDialect = true;
|
|
1320
|
+
stats.path_params_skipped_dialect++;
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
const resolved = new Map();
|
|
1324
|
+
for (const p of pathParams) {
|
|
1325
|
+
const name = safeParamName(p);
|
|
1326
|
+
if (name === null || !Object.hasOwn(p, 'schema')) continue; // no name, or source declared no schema -- nothing to prefer over the heuristic for this one segment
|
|
1327
|
+
const schemaNode = p.schema;
|
|
1328
|
+
if (!schemaNode || typeof schemaNode !== 'object' || Array.isArray(schemaNode)) continue;
|
|
1329
|
+
const out = inlineSchema(schemaNode, componentSchemas, { includeFieldDocs });
|
|
1330
|
+
if (out.ok) resolved.set(name, out.schema);
|
|
1331
|
+
}
|
|
1332
|
+
if (resolved.size > 0) {
|
|
1333
|
+
result.pathParamSchemas = resolved;
|
|
1334
|
+
stats.path_params_copied++;
|
|
1335
|
+
} else {
|
|
1336
|
+
stats.path_params_unresolved++;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1108
1340
|
// A7: the single entry point called from reconcileModule()'s two matched/adopted call sites --
|
|
1109
1341
|
// exactly the placement A2/A3's own helpers already occupy, which IS the refusal mechanism for
|
|
1110
1342
|
// every other resolution kind (drift/missing/ambiguous/unresolved never reach this function at
|
|
1111
1343
|
// all, see reconcileModule below).
|
|
1112
|
-
function applyPassthrough(result, docEntry, index, stats, schemaProjectionEnabled, referencedSchemeNames) {
|
|
1113
|
-
|
|
1344
|
+
function applyPassthrough(result, docEntry, index, stats, schemaProjectionEnabled, referencedSchemeNames, includeDescriptions) {
|
|
1345
|
+
// A11 (D-openapi-field-docs): includeDescriptions doubles as includeFieldDocs here -- reusing
|
|
1346
|
+
// the existing --descriptions flag rather than adding a second one, since it already governs
|
|
1347
|
+
// "copy source-authored documentation prose" at the operation level (A10); field-level
|
|
1348
|
+
// description/example is the same policy applied one level deeper into the same schemas.
|
|
1349
|
+
applyParameters(result, docEntry, index, stats, schemaProjectionEnabled, includeDescriptions);
|
|
1114
1350
|
applySecurity(result, docEntry, index, stats, referencedSchemeNames);
|
|
1115
1351
|
applySummaryAndTags(result, docEntry, stats);
|
|
1116
1352
|
// A8: same matched/adopted-only placement as the three calls above -- this IS the refusal
|
|
1117
1353
|
// 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);
|
|
1354
|
+
applyPerStatusResponses(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled, includeDescriptions);
|
|
1355
|
+
applyRequestMediaTypes(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled, includeDescriptions);
|
|
1356
|
+
// A9: same placement again, for the path-parameter schema fix.
|
|
1357
|
+
applyPathParameterSchemas(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled, includeDescriptions);
|
|
1358
|
+
// A10: same placement again -- applyDescription() itself decides whether includeDescriptions
|
|
1359
|
+
// gates it off, matching how applyParameters decides its own schemaProjectionEnabled gate
|
|
1360
|
+
// internally rather than being skipped by the caller.
|
|
1361
|
+
applyDescription(result, docEntry, stats, includeDescriptions);
|
|
1120
1362
|
}
|
|
1121
1363
|
|
|
1122
1364
|
// The core reconciliation, pure (no I/O). `module` is a scanReport related_modules entry (as
|
|
@@ -1124,7 +1366,7 @@ function applyPassthrough(result, docEntry, index, stats, schemaProjectionEnable
|
|
|
1124
1366
|
// selection buildContract() will use, so endpointKey(ci,ei) lines up). `pathPrefix`, if given
|
|
1125
1367
|
// (from --path-prefix), overrides inference entirely but the anchor pass still runs so its
|
|
1126
1368
|
// deltas are recorded for audit in the snapshot.
|
|
1127
|
-
export function reconcileModule({ index, module, pathPrefix = null }) {
|
|
1369
|
+
export function reconcileModule({ index, module, pathPrefix = null, includeDescriptions = false }) {
|
|
1128
1370
|
const anchorDeltas = [];
|
|
1129
1371
|
for (const controller of module.controllers) {
|
|
1130
1372
|
for (const ep of controller.endpoints) {
|
|
@@ -1164,6 +1406,11 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
|
|
|
1164
1406
|
// the A7 counters above.
|
|
1165
1407
|
per_status_copied: 0, per_status_skipped_unresolved: 0, per_status_none: 0, per_status_skipped_dialect: 0,
|
|
1166
1408
|
request_media_types_copied: 0, request_media_types_unresolved: 0, request_media_types_none: 0, request_media_types_skipped_dialect: 0,
|
|
1409
|
+
// A9: source-backed path-parameter schema counters, same per-operation-tally shape.
|
|
1410
|
+
path_params_copied: 0, path_params_unresolved: 0, path_params_none: 0, path_params_skipped_dialect: 0,
|
|
1411
|
+
// A10: operation-level description counters. skipped_flag is the common case when
|
|
1412
|
+
// --descriptions was not passed -- distinct from `none` (flag WAS passed, source had nothing).
|
|
1413
|
+
description_copied: 0, description_unresolved: 0, description_none: 0, description_skipped_flag: 0,
|
|
1167
1414
|
};
|
|
1168
1415
|
// A7: accumulates every security-scheme name any operation's COPIED security requirement
|
|
1169
1416
|
// actually referenced, across the WHOLE module -- becomes the contract-root sourceSecuritySchemes
|
|
@@ -1206,14 +1453,14 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
|
|
|
1206
1453
|
// A2/A3: matched/adopted ONLY -- schema enrichment never applies to drift/missing/
|
|
1207
1454
|
// ambiguous/unresolved, same "don't guess" rule A1 established for path/verb.
|
|
1208
1455
|
if (schemaProjection.enabled) {
|
|
1209
|
-
applyRequestBodySchema(result, docEntry, index.componentSchemas, stats);
|
|
1210
|
-
applyResponseSchemas(result, docEntry, index.componentSchemas, stats);
|
|
1456
|
+
applyRequestBodySchema(result, docEntry, index.componentSchemas, stats, includeDescriptions);
|
|
1457
|
+
applyResponseSchemas(result, docEntry, index.componentSchemas, stats, includeDescriptions);
|
|
1211
1458
|
}
|
|
1212
1459
|
// A7: same matched/adopted-only placement -- this IS the refusal mechanism for
|
|
1213
1460
|
// every other resolution kind. Called unconditionally (not gated on
|
|
1214
1461
|
// schemaProjection.enabled): parameters gate internally (schema-bearing);
|
|
1215
1462
|
// security/summary/tags are dialect-independent and always attempted.
|
|
1216
|
-
applyPassthrough(result, docEntry, index, stats, schemaProjection.enabled, referencedSecuritySchemeNames);
|
|
1463
|
+
applyPassthrough(result, docEntry, index, stats, schemaProjection.enabled, referencedSecuritySchemeNames, includeDescriptions);
|
|
1217
1464
|
} else {
|
|
1218
1465
|
result = {
|
|
1219
1466
|
kind: 'drift', reason: 'path',
|
|
@@ -1239,10 +1486,10 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
|
|
|
1239
1486
|
};
|
|
1240
1487
|
stats.adopted++;
|
|
1241
1488
|
if (schemaProjection.enabled) {
|
|
1242
|
-
applyRequestBodySchema(result, hits[0], index.componentSchemas, stats);
|
|
1243
|
-
applyResponseSchemas(result, hits[0], index.componentSchemas, stats);
|
|
1489
|
+
applyRequestBodySchema(result, hits[0], index.componentSchemas, stats, includeDescriptions);
|
|
1490
|
+
applyResponseSchemas(result, hits[0], index.componentSchemas, stats, includeDescriptions);
|
|
1244
1491
|
}
|
|
1245
|
-
applyPassthrough(result, hits[0], index, stats, schemaProjection.enabled, referencedSecuritySchemeNames);
|
|
1492
|
+
applyPassthrough(result, hits[0], index, stats, schemaProjection.enabled, referencedSecuritySchemeNames, includeDescriptions);
|
|
1246
1493
|
} else if (hits.length === 1) {
|
|
1247
1494
|
// A single route match, but the document itself never gave that operation an
|
|
1248
1495
|
// operationId -- nothing to route by, so this can't become an addressable
|
|
@@ -1277,7 +1524,7 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
|
|
|
1277
1524
|
|
|
1278
1525
|
// Convenience entry point: load + index + reconcile in one call, propagating the first failure.
|
|
1279
1526
|
// This is what bin/bskel.mjs's cmdContractEmit calls.
|
|
1280
|
-
export function buildReconciliation({ filePath, module, pathPrefix = null }) {
|
|
1527
|
+
export function buildReconciliation({ filePath, module, pathPrefix = null, includeDescriptions = false }) {
|
|
1281
1528
|
if (pathPrefix != null && !PATH_PREFIX_RE.test(pathPrefix)) {
|
|
1282
1529
|
return { ok: false, error: `--path-prefix "${pathPrefix}" is not a valid path prefix (expected e.g. "/api/v0")` };
|
|
1283
1530
|
}
|
|
@@ -1301,7 +1548,7 @@ export function buildReconciliation({ filePath, module, pathPrefix = null }) {
|
|
|
1301
1548
|
}
|
|
1302
1549
|
const indexed = indexOpenApiDocument(loaded.doc);
|
|
1303
1550
|
if (!indexed.ok) return indexed;
|
|
1304
|
-
const recon = reconcileModule({ index: indexed, module, pathPrefix });
|
|
1551
|
+
const recon = reconcileModule({ index: indexed, module, pathPrefix, includeDescriptions });
|
|
1305
1552
|
return {
|
|
1306
1553
|
ok: true,
|
|
1307
1554
|
document: {
|
|
@@ -1325,6 +1572,10 @@ export function buildReconciliation({ filePath, module, pathPrefix = null }) {
|
|
|
1325
1572
|
// A7: only schemes actually referenced by at least one copied sourceSecurity requirement --
|
|
1326
1573
|
// contracts/emit.mjs attaches this at the contract root when non-empty.
|
|
1327
1574
|
sourceSecuritySchemes: recon.sourceSecuritySchemes,
|
|
1575
|
+
// D-unsupported-annotation-warning: computed once per document, not per-operation -- a
|
|
1576
|
+
// module-wide presence signal, not a per-operation fact, so contracts/emit.mjs pushes at
|
|
1577
|
+
// most one warning per keyword name for the whole module, not one per operation.
|
|
1578
|
+
unsupportedAnnotations: findUnsupportedAnnotations(loaded.doc),
|
|
1328
1579
|
};
|
|
1329
1580
|
}
|
|
1330
1581
|
|
|
@@ -1365,6 +1616,18 @@ function requestMediaTypesDecision(result) {
|
|
|
1365
1616
|
if (result.requestMediaTypesUnresolvedReason) return `unresolved:${result.requestMediaTypesUnresolvedReason}`;
|
|
1366
1617
|
return 'none';
|
|
1367
1618
|
}
|
|
1619
|
+
// A9: same decision-only audit-trail shape, for the path-parameter schema fix.
|
|
1620
|
+
function pathParamSchemasDecision(result) {
|
|
1621
|
+
if (result.pathParamsSkippedDialect) return 'skipped:dialect';
|
|
1622
|
+
if (result.pathParamSchemas) return `copied:${result.pathParamSchemas.size}`;
|
|
1623
|
+
return 'none';
|
|
1624
|
+
}
|
|
1625
|
+
// A10: same decision-only audit-trail shape as every field above.
|
|
1626
|
+
function descriptionDecision(result) {
|
|
1627
|
+
if (result.sourceDescription) return 'copied';
|
|
1628
|
+
if (result.descriptionUnresolvedReason) return `unresolved:${result.descriptionUnresolvedReason}`;
|
|
1629
|
+
return 'none';
|
|
1630
|
+
}
|
|
1368
1631
|
|
|
1369
1632
|
// `sourceFile`: {file, outsideRepo} precomputed by the caller (bin/bskel.mjs knows the repo
|
|
1370
1633
|
// root; this module deliberately doesn't) -- keeps machine-specific absolute paths out of a
|
|
@@ -1405,6 +1668,10 @@ export function snapshotFromReconciliation(reconciliation, { featureId, sourceFi
|
|
|
1405
1668
|
// A8: same decision-only audit trail, for the two new passthrough fields.
|
|
1406
1669
|
per_status_responses: perStatusResponsesDecision(result),
|
|
1407
1670
|
request_media_types: requestMediaTypesDecision(result),
|
|
1671
|
+
// A9: same decision-only audit trail, for the path-parameter schema fix.
|
|
1672
|
+
path_param_schemas: pathParamSchemasDecision(result),
|
|
1673
|
+
// A10: same decision-only audit trail, for the opt-in operation-level description.
|
|
1674
|
+
description: descriptionDecision(result),
|
|
1408
1675
|
};
|
|
1409
1676
|
}
|
|
1410
1677
|
}
|