backend-skeleton 1.0.0 → 1.1.0
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 +66 -4
- package/bin/bskel.mjs +125 -18
- package/contracts/export.mjs +39 -4
- package/contracts/openapi.mjs +292 -27
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +75 -32
- package/handles/capability-codec.mjs +94 -0
- package/handles/codec.mjs +13 -3
- package/handles/providers/java-spring/emit.mjs +78 -33
- package/handles/providers/java-spring/observe.mjs +4 -3
- package/handles/providers/java-spring/plan.mjs +51 -7
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
- package/handles/providers/java-spring.mjs +8 -0
- package/handles/providers/python-fastapi/emit.mjs +21 -26
- package/handles/providers/python-fastapi/observe.mjs +6 -5
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +135 -46
- package/handles/providers/typescript-express/observe.mjs +7 -6
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
- package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
- package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
- package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
- package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
- package/handles/providers/typescript-express.mjs +7 -4
- package/lib/cli.mjs +11 -2
- package/lib/exit-codes.mjs +21 -0
- package/lib/verify.mjs +23 -6
- package/package.json +5 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
- package/scanners/adapters/java-spring.mjs +108 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/typescript-express.mjs +13 -2
- package/schemas/feature-contract.schema.json +3 -3
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.schema.json +58 -0
- package/schemas/stack-record.schema.json +6 -1
- package/stack/apply.mjs +47 -6
package/contracts/openapi.mjs
CHANGED
|
@@ -28,10 +28,32 @@ const HTTP_METHODS = Object.freeze(new Set(['get', 'put', 'post', 'delete', 'opt
|
|
|
28
28
|
// D-openapi-request-schema in DECISIONS.md for the full measurement.
|
|
29
29
|
const MAX_COMPONENT_SCHEMAS = 5000;
|
|
30
30
|
const MAX_SCHEMA_DEPTH = 32; // every recursion level (structural AND $ref), not just $ref-chain depth
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// D-openapi-schema-keyword-recursion (CATALOG A15/A16 follow-up): widened 2000 -> 250000. The
|
|
32
|
+
// original 2000 had no real-data citation (an uncited default). After A15/A16's own masking-
|
|
33
|
+
// cascade fixes unmasked 30 real request/response schema resolutions genuinely needing more than
|
|
34
|
+
// 2000 nodes, a real per-schema binary-search probe (not guessed) found needs ranging 2289-61304
|
|
35
|
+
// against polarsource/polar. Investigated the worst case directly before widening anything (this
|
|
36
|
+
// project's own "measure, don't guess" discipline applies doubly hard to a DoS-defensive cap, not
|
|
37
|
+
// just an operational-default one): `GET /v1/events/`'s 200 response is `anyOf` of two list
|
|
38
|
+
// wrappers (regular + cursor-paginated), each independently embedding a full copy of `Event` ->
|
|
39
|
+
// `oneOf[SystemEvent, UserEvent]` -> `SystemEvent.oneOf` (36 REAL distinct event subtypes, a real
|
|
40
|
+
// webhook/audit event taxonomy) -- roughly 2x one full Event tree's cost, from a genuinely wide
|
|
41
|
+
// real API surface, not pathological duplication or a runaway loop. 250000 keeps this project's
|
|
42
|
+
// own established "~4x real observed max" headroom convention (MAX_COMPONENT_SCHEMAS 4.8x,
|
|
43
|
+
// MAX_PATTERN_LENGTH 3.5x, MAX_PARAMETERS_PER_OPERATION 3.8x) rather than a tight fit to 61304.
|
|
44
|
+
const MAX_SCHEMA_NODES = 250000; // shared counter per top-level inlineSchema() call, enum entries count
|
|
45
|
+
// D-oracle-corpus-openapi-remeasurement (ROADMAP Phase 5c): widened 300 -> 1000. Real max
|
|
46
|
+
// observed against Team-IZ-Backend: 77. Re-measured against a second, much larger real
|
|
47
|
+
// production document (polarsource/polar, 1046 component schemas vs Team-IZ-Backend's 308): real
|
|
48
|
+
// max 286 (SupportCaseAttachmentFileCreate) -- 95% of the OLD 300 cap, headroom effectively gone.
|
|
49
|
+
// 1000 restores >3.5x headroom over the new, larger real max.
|
|
50
|
+
const MAX_PATTERN_LENGTH = 1000; // real max observed: 77 (Team-IZ-Backend), 286 (polarsource/polar)
|
|
33
51
|
const JSON_MEDIA_TYPE = 'application/json';
|
|
34
52
|
const SCHEMA_REF_PREFIX = '#/components/schemas/';
|
|
53
|
+
// D-openapi-cyclic-refs: the ONLY $ref form this module's own output ever emits (a genuinely
|
|
54
|
+
// cyclic component, resolved into a top-level `$defs` map -- see inlineSchema()). Distinct from
|
|
55
|
+
// SCHEMA_REF_PREFIX, which is what this module RESOLVES on the way IN from a source document.
|
|
56
|
+
const DEFS_REF_PREFIX = '#/$defs/';
|
|
35
57
|
|
|
36
58
|
// A3: response/error JSON Schema projection. Reuses every inlineSchema() defense above
|
|
37
59
|
// unchanged (keyword/format whitelist, MAX_SCHEMA_DEPTH/NODES/PATTERN_LENGTH) -- measured by
|
|
@@ -113,7 +135,22 @@ export const PATH_PREFIX_RE = /^(?:\/[A-Za-z0-9._~%-]+)+$/;
|
|
|
113
135
|
// requirement kills `__proto__` on the first character alone, same as OPERATION_ID_RE; measured
|
|
114
136
|
// against all 308 real Team-IZ-Backend component-schema names with zero rejections.
|
|
115
137
|
export const COMPONENT_SCHEMA_NAME_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,199}$/;
|
|
116
|
-
|
|
138
|
+
// D-oracle-corpus-openapi-remeasurement (ROADMAP Phase 5c): widened from
|
|
139
|
+
// /^[A-Za-z][A-Za-z0-9_]{0,127}$/ after re-measuring against polarsource/polar's real production
|
|
140
|
+
// document found 4 real property names the ORIGINAL pattern rejected: `_cost`, `_llm`
|
|
141
|
+
// (single-leading-underscore, a real, common convention for "internal/computed field" this
|
|
142
|
+
// corpus's own Team-IZ-Backend measurement never happened to exercise), `cf-turnstile-response`
|
|
143
|
+
// (a real kebab-case name from a third-party integration), and `$ref` (a real domain property
|
|
144
|
+
// LITERALLY named $ref, e.g. BenefitCustom.properties.$ref -- correctly still rejected below, not
|
|
145
|
+
// a false positive to fix: $ starts neither the required leading letter nor the new optional
|
|
146
|
+
// leading underscore). The widened pattern (`_?[A-Za-z][A-Za-z0-9_-]{0,127}`) allows AT MOST ONE
|
|
147
|
+
// leading underscore followed immediately by a letter -- this still kills `__proto__` (two
|
|
148
|
+
// underscores; after consuming one, the required next character is the SECOND underscore, not a
|
|
149
|
+
// letter, so it fails either way the optional group backtracks) and every other real
|
|
150
|
+
// Object.prototype dunder name (`__defineGetter__`, etc.), confirmed by a real regression test,
|
|
151
|
+
// not reasoned about only in this comment. Hyphens added (matching COMPONENT_SCHEMA_NAME_RE's own
|
|
152
|
+
// already-permissive character class) for the same real `cf-turnstile-response` case.
|
|
153
|
+
export const SCHEMA_PROPERTY_NAME_RE = /^_?[A-Za-z][A-Za-z0-9_-]{0,127}$/;
|
|
117
154
|
|
|
118
155
|
// A8: an OpenAPI response-object status key becomes an object key downstream, in both this
|
|
119
156
|
// contract's sourceResponses and the exported document's responses -- same prototype-pollution
|
|
@@ -155,7 +192,13 @@ export const PER_STATUS_NO_DESCRIPTION_STANDIN = 'The source document documents
|
|
|
155
192
|
// dropping an assertion (e.g. an unrecognized `pattern`-like keyword) would emit a schema WEAKER
|
|
156
193
|
// than the real one, which is worse than emitting no schema at all -- see
|
|
157
194
|
// D-openapi-request-schema in DECISIONS.md.
|
|
158
|
-
|
|
195
|
+
// A15: `propertyNames` added -- its value IS a schema (unlike every other RECURSED_KEYWORDS
|
|
196
|
+
// sibling being a container OF schemas), so findUnsupportedAnnotations() must descend into it the
|
|
197
|
+
// same way it already descends into `items`, or a dropped/unsupported keyword nested inside a
|
|
198
|
+
// propertyNames sub-schema would go completely unreported.
|
|
199
|
+
// A16: `prefixItems` added -- an ARRAY of schemas (same shape as oneOf/anyOf/allOf), already
|
|
200
|
+
// covered by this function's existing `Array.isArray(value)` branch with zero further code change.
|
|
201
|
+
const RECURSED_KEYWORDS = Object.freeze(new Set(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf', 'propertyNames', 'prefixItems']));
|
|
159
202
|
// A7: `default` added -- annotation-only per 2020-12 (Ajv runs with useDefaults off here, so it's
|
|
160
203
|
// inert for validation either way), but a real, human-authored fact from the source document worth
|
|
161
204
|
// carrying through regardless. Measured across every real request-body and response schema in the
|
|
@@ -172,13 +215,49 @@ const COPIED_KEYWORDS = Object.freeze(new Set([
|
|
|
172
215
|
// A11 (D-openapi-field-docs): `description`/`example` measured real and heavily used at the FIELD
|
|
173
216
|
// level (3,982 / 2,077 occurrences across the oracle's request/response/parameter schemas,
|
|
174
217
|
// 520,527 / 32,708 real bytes) -- moved out of DROPPED_KEYWORDS into their own conditionally-
|
|
175
|
-
// copied set.
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
218
|
+
// copied set.
|
|
219
|
+
// A14 (D-openapi-field-metadata-passthrough): `title`/`examples`(plural)/`deprecated` join them.
|
|
220
|
+
// A11's own "0 real occurrences, permanently unbuilt" verdict for all five held against the
|
|
221
|
+
// original Team-IZ-Backend oracle (308 component schemas) but NOT against a real, much larger
|
|
222
|
+
// second corpus (polarsource/polar, 1046 component schemas, ROADMAP Phase 5c's own
|
|
223
|
+
// D-oracle-corpus-openapi-remeasurement): title 6,226 real occurrences (max 54 chars), examples
|
|
224
|
+
// 602 real arrays (max 4 entries, max element 59 chars serialized), deprecated 31 real boolean
|
|
225
|
+
// occurrences. `externalDocs`/`xml` remain confirmed 0 real occurrences even at this larger
|
|
226
|
+
// scale -- those two alone still satisfy the "don't build for zero real cases" discipline A8's
|
|
227
|
+
// `non-json-response-schemas`/`response-headers` also rest on.
|
|
228
|
+
// A15 (D-openapi-schema-keyword-recursion): `readOnly` joins the documentation set -- 2020-12
|
|
229
|
+
// section 9.4 marks readOnly/writeOnly as annotation-only (no effect on validation, same as
|
|
230
|
+
// deprecated), and it has the identical boolean shape. Measured real (polarsource/polar): 40
|
|
231
|
+
// occurrences, 0 malformed (non-boolean).
|
|
232
|
+
const DOCUMENTATION_KEYWORDS = Object.freeze(new Set(['description', 'example', 'title', 'examples', 'deprecated', 'readOnly']));
|
|
233
|
+
// A15: `x-speakeasy-enums`/`enumNames` join the dropped set. Both are vendor/non-standard codegen
|
|
234
|
+
// metadata (Speakeasy's own enum-codegen hint; enumNames is a react-jsonschema-form-style
|
|
235
|
+
// human-label convention) with zero JSON Schema validation weight -- the same risk tier as
|
|
236
|
+
// externalDocs/xml. Real counts (polarsource/polar, measured AFTER the uuid4 masking fix below):
|
|
237
|
+
// x-speakeasy-enums 59, enumNames 1. Both already reachable via the existing RECURSED_KEYWORDS
|
|
238
|
+
// walk (they appear as direct schema children), so findUnsupportedAnnotations() needs no other
|
|
239
|
+
// change to start reporting them.
|
|
240
|
+
//
|
|
241
|
+
// D-openapi-schema-keyword-recursion masking note: Phase 5c's own
|
|
242
|
+
// D-oracle-corpus-openapi-remeasurement recorded "26 remaining failures (discriminator x2,
|
|
243
|
+
// x-speakeasy-enums x4, propertyNames x20)" against this exact corpus. Re-measuring after the
|
|
244
|
+
// uuid4 fix landed (this
|
|
245
|
+
// file's SAFE_FORMATS/format handling below) revealed those Phase 5c counts were themselves an
|
|
246
|
+
// undercount: walkSchemaNode() throws on the FIRST unsupported key it hits in a node and never
|
|
247
|
+
// reaches the rest, so a node with BOTH format:uuid4 AND, say, a discriminator had its
|
|
248
|
+
// discriminator failure permanently masked by the uuid4 failure firing first. Fixing uuid4 didn't
|
|
249
|
+
// just fix uuid4 -- it unmasked failures that were always there. Real post-fix counts: x-speakeasy-
|
|
250
|
+
// enums 59, readOnly 25 (not even named in Phase 5c -- fully masked), propertyNames 24,
|
|
251
|
+
// discriminator 9, enumNames 1 (also unnamed in Phase 5c). `cycle-detected` (4, unrelated to
|
|
252
|
+
// masking) stays out of scope -- a real circular schema reference, not a missing keyword; this
|
|
253
|
+
// flatten-only architecture has no $ref-preserving output mode to represent one.
|
|
254
|
+
const DROPPED_KEYWORDS = Object.freeze(new Set(['externalDocs', 'xml', 'x-speakeasy-enums', 'enumNames']));
|
|
255
|
+
// A14: real max observed (polarsource/polar) -- title 54 chars, examples array 4 entries. Both
|
|
256
|
+
// generously round, matching MAX_EXAMPLE_LENGTH's own "generously round, not a tight multiple"
|
|
257
|
+
// precedent (a legitimately useful title/example set could reasonably run longer than any single
|
|
258
|
+
// real value happened to here).
|
|
259
|
+
const MAX_TITLE_LENGTH = 500;
|
|
260
|
+
const MAX_EXAMPLES_ARRAY_LENGTH = 50;
|
|
182
261
|
|
|
183
262
|
// D-unsupported-annotation-warning: 0 real occurrences on the ONE oracle this whole module's
|
|
184
263
|
// caps/keyword sets were measured against does not mean 0 occurrences everywhere -- a genuinely
|
|
@@ -187,8 +266,9 @@ const DROPPED_KEYWORDS = Object.freeze(new Set(['title', 'examples', 'externalDo
|
|
|
187
266
|
// for the broader "self-identified weaknesses" context this closes one instance of).
|
|
188
267
|
//
|
|
189
268
|
// Deliberately walks ONLY genuine Schema Object structure (via RECURSED_KEYWORDS, the exact same
|
|
190
|
-
// `properties`/`items`/`additionalProperties`/`oneOf`/`anyOf`/`allOf`
|
|
191
|
-
// recurses through) -- NOT a blanket "every key anywhere in the document"
|
|
269
|
+
// `properties`/`items`/`additionalProperties`/`oneOf`/`anyOf`/`allOf`/`propertyNames`/`prefixItems`
|
|
270
|
+
// set inlineSchema() itself recurses through) -- NOT a blanket "every key anywhere in the document"
|
|
271
|
+
// scan. That distinction
|
|
192
272
|
// is load-bearing, not cosmetic: several of DROPPED_KEYWORDS' names collide with REAL, unrelated
|
|
193
273
|
// OpenAPI concepts that live outside a Schema Object entirely -- an Operation Object's own
|
|
194
274
|
// `deprecated` (marks a whole ENDPOINT deprecated) and a Parameter Object's own `deprecated`, both
|
|
@@ -273,7 +353,19 @@ export function findUnsupportedAnnotations(doc) {
|
|
|
273
353
|
// Real Team-IZ-Backend format-value histogram (request-body-reachable schemas only): uuid(20),
|
|
274
354
|
// int32(10), email(7), date(10), date-time(3), int64(2). `uuid` is handled separately (rewritten
|
|
275
355
|
// to BARE_UUID_PATTERN, see inlineSchema) -- not in this set, since it never survives as `format`.
|
|
276
|
-
|
|
356
|
+
// D-oracle-corpus-openapi-remeasurement (ROADMAP Phase 5c): re-measured against
|
|
357
|
+
// polarsource/polar's real production document (full histogram, all schema roots): uuid4(1063,
|
|
358
|
+
// see inlineSchema's own uuid4 handling -- also not in this set for the same reason bare uuid
|
|
359
|
+
// isn't), date-time(676), uri(41), email(28), uuid(27), date(7), ipvanyaddress(7), duration(2),
|
|
360
|
+
// color(1). Every value already in SAFE_FORMATS before this measurement (date-time/uri/email/date)
|
|
361
|
+
// is confirmed still real and safe at a much larger scale.
|
|
362
|
+
// A15: `ipvanyaddress`/`duration`/`color` added despite being the rarest formats measured (7/2/1
|
|
363
|
+
// occurrences) -- Phase 5c judged that too rare to justify adding on its own, but SAFE_FORMATS is
|
|
364
|
+
// a pure passthrough whitelist (this module never validates a format VALUE, only decides whether
|
|
365
|
+
// the format NAME is safe to keep), so the marginal cost of adding a real, if rare, format name is
|
|
366
|
+
// low and the alternative (3 real fields permanently fail-closed) is a real, visible cost with no
|
|
367
|
+
// offsetting benefit once a real occurrence exists at all.
|
|
368
|
+
const SAFE_FORMATS = Object.freeze(new Set(['int32', 'int64', 'email', 'date', 'date-time', 'double', 'float', 'binary', 'uri', 'ipvanyaddress', 'duration', 'color']));
|
|
277
369
|
|
|
278
370
|
// Thrown internally by inlineSchema()'s recursive walk and caught exactly once at the exported
|
|
279
371
|
// boundary -- with 12+ distinct failure points, threading {ok:false} through every return would
|
|
@@ -573,14 +665,27 @@ export function inferPathPrefix(anchorDeltas) {
|
|
|
573
665
|
}
|
|
574
666
|
|
|
575
667
|
// A2: dereferences `node` (a schema fragment from a requestBody's application/json content) into
|
|
576
|
-
// a single self-contained JSON Schema tree
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
668
|
+
// a single self-contained JSON Schema tree. Pure, and NEVER throws across this exported boundary
|
|
669
|
+
// (InlineFailure is caught here, anything else re-thrown -- it would be a real programming bug,
|
|
670
|
+
// not an untrusted-input failure, and must not be swallowed). Full inlining (never registering a
|
|
671
|
+
// component with ajv by $id) for two independent reasons: ajv would otherwise need every one of a
|
|
672
|
+
// document's component schemas registered just to validate ONE operation's body, and
|
|
673
|
+
// bin/bskel.mjs's cmdContractToolSchema promises its `input_schema` output is a JSON Schema
|
|
674
|
+
// subset "directly usable as-is" for Anthropic tool-use, which forbids RECURSIVE schemas
|
|
675
|
+
// specifically (internal $ref/$defs to a non-recursive shared definition is fine, per Anthropic's
|
|
676
|
+
// own documented JSON Schema limitations) -- this function is what upholds both promises.
|
|
677
|
+
//
|
|
678
|
+
// D-openapi-cyclic-refs: the ONE exception to "no $ref anywhere in the output" -- a genuinely
|
|
679
|
+
// self-referential component (directly or via a mutual cycle, e.g. polarsource/polar's real
|
|
680
|
+
// `Filter` and `Meter`<->`Subscription`<->`SubscriptionMeter`) can never terminate under full
|
|
681
|
+
// inlining; walkSchemaNode() now emits `$ref: "#/$defs/<Name>"` at exactly the cyclic edge
|
|
682
|
+
// (state.defsNeeded, populated during the walk) instead of failing closed, and this function
|
|
683
|
+
// drains that set ONCE the top-level walk completes into a real, top-level `$defs` map attached
|
|
684
|
+
// to the returned schema -- omitted entirely (this function's return shape is unchanged) for the
|
|
685
|
+
// overwhelmingly common acyclic case. `cmdContractToolSchema` is the one real consumer this
|
|
686
|
+
// genuinely cannot satisfy (Anthropic's own "recursive schemas not supported" limitation) --
|
|
687
|
+
// it detects `$defs` on the projected schema and refuses explicitly rather than emitting
|
|
688
|
+
// something that would only fail later at the real API call. See DECISIONS.md.
|
|
584
689
|
export function inlineSchema(node, componentSchemas, opts = {}) {
|
|
585
690
|
const limits = {
|
|
586
691
|
maxDepth: opts.maxDepth ?? MAX_SCHEMA_DEPTH,
|
|
@@ -590,9 +695,40 @@ export function inlineSchema(node, componentSchemas, opts = {}) {
|
|
|
590
695
|
// byte-for-byte when the caller doesn't pass it) -- see D-openapi-field-docs.
|
|
591
696
|
includeFieldDocs: opts.includeFieldDocs ?? false,
|
|
592
697
|
};
|
|
593
|
-
const state = { nodes: 0 };
|
|
698
|
+
const state = { nodes: 0, defsNeeded: new Set() };
|
|
594
699
|
try {
|
|
595
700
|
const schema = walkSchemaNode(node, componentSchemas, 0, new Set(), state, limits);
|
|
701
|
+
if (state.defsNeeded.size > 0) {
|
|
702
|
+
const defs = {};
|
|
703
|
+
const resolved = new Set();
|
|
704
|
+
// Fixed-point drain: resolving one cyclic component's own body can discover ANOTHER
|
|
705
|
+
// cyclic component reachable from it (a mutual cycle, e.g. Meter -> Subscription ->
|
|
706
|
+
// SubscriptionMeter -> Meter) that state.defsNeeded didn't have yet -- one pass over a
|
|
707
|
+
// snapshot would miss it.
|
|
708
|
+
let pending = [...state.defsNeeded].filter((name) => !resolved.has(name));
|
|
709
|
+
while (pending.length > 0) {
|
|
710
|
+
for (const name of pending) {
|
|
711
|
+
resolved.add(name);
|
|
712
|
+
const target = componentSchemas.get(name); // presence already confirmed during the main walk
|
|
713
|
+
const def = walkSchemaNode(target, componentSchemas, 0, new Set([name]), state, limits);
|
|
714
|
+
// A component whose ENTIRE definition is nothing but a $ref chain back to
|
|
715
|
+
// itself (real content -- type/properties/etc -- never appears ANYWHERE in
|
|
716
|
+
// the cycle) resolves to a bare `{$ref: ...}` with no other real keys.
|
|
717
|
+
// Confirmed live: Ajv2020 itself stack-overflows trying to COMPILE such a
|
|
718
|
+
// schema (an infinite pure-indirection loop with no base case for its own
|
|
719
|
+
// compiler to terminate on) -- this is not a shape any real OpenAPI document
|
|
720
|
+
// produces (every real cyclic component measured has genuine structural
|
|
721
|
+
// content at every level, e.g. polarsource/polar's Filter/Meter/Subscription),
|
|
722
|
+
// so fail closed on this degenerate case rather than emit something that
|
|
723
|
+
// would break the very validator this whole mechanism exists to feed.
|
|
724
|
+
const realKeys = Object.keys(def).filter((k) => k !== '$ref' && k !== 'default');
|
|
725
|
+
if (realKeys.length === 0) fail('cycle-without-base-schema');
|
|
726
|
+
defs[name] = def;
|
|
727
|
+
}
|
|
728
|
+
pending = [...state.defsNeeded].filter((name) => !resolved.has(name));
|
|
729
|
+
}
|
|
730
|
+
schema.$defs = defs;
|
|
731
|
+
}
|
|
596
732
|
return { ok: true, schema, nodes: state.nodes };
|
|
597
733
|
} catch (err) {
|
|
598
734
|
if (err instanceof InlineFailure) return { ok: false, reason: err.reason };
|
|
@@ -629,7 +765,22 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
|
|
|
629
765
|
// JSON-Pointer escapes (~0/~1) or percent-encoding in the name are never produced by
|
|
630
766
|
// springdoc for a plain component name -- reject rather than decode-and-guess.
|
|
631
767
|
if (name.includes('~') || name.includes('%') || !COMPONENT_SCHEMA_NAME_RE.test(name)) fail('unsupported-ref');
|
|
632
|
-
if (visiting.has(name))
|
|
768
|
+
if (visiting.has(name)) {
|
|
769
|
+
// D-openapi-cyclic-refs: a genuine ancestor-chain cycle (name is currently being
|
|
770
|
+
// resolved higher up this same call stack) -- this is a deterministic property of
|
|
771
|
+
// `name`'s own structure (does it reach itself via ANY of its own oneOf/anyOf/allOf/
|
|
772
|
+
// properties/items branches, all of which are walked unconditionally, never
|
|
773
|
+
// simulating "one instance"), not of which reference site discovered it first: every
|
|
774
|
+
// occurrence of a self-referential component hits this same branch, every occurrence
|
|
775
|
+
// of a non-cyclic one fully inlines exactly as before -- no two-pass discovery needed.
|
|
776
|
+
// Recorded in state.defsNeeded and drained once, after the top-level walk completes
|
|
777
|
+
// (see inlineSchema()), into a real `$defs` map; this $ref stub is the only place in
|
|
778
|
+
// the whole module that ever emits an unresolved $ref in the OUTPUT.
|
|
779
|
+
state.defsNeeded.add(name);
|
|
780
|
+
const stub = { '$ref': `${DEFS_REF_PREFIX}${name}` };
|
|
781
|
+
if (Object.hasOwn(node, 'default')) stub.default = node.default;
|
|
782
|
+
return stub;
|
|
783
|
+
}
|
|
633
784
|
const target = componentSchemas.get(name);
|
|
634
785
|
if (!target) fail('component-not-found');
|
|
635
786
|
visiting.add(name);
|
|
@@ -657,7 +808,18 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
|
|
|
657
808
|
// uuid+pattern conflict check below (inside the loop) sees `out.pattern` already set.
|
|
658
809
|
if (Object.hasOwn(node, 'format')) {
|
|
659
810
|
const format = node.format;
|
|
660
|
-
|
|
811
|
+
// D-oracle-corpus-openapi-remeasurement (ROADMAP Phase 5c): `uuid4` added alongside `uuid`
|
|
812
|
+
// -- real, measured data, not a speculative generalization. polarsource/polar's real,
|
|
813
|
+
// production OpenAPI document uses `format: "uuid4"` 1063 times (Pydantic's UUID4 type
|
|
814
|
+
// emits this non-standard-but-common format string) and NONE use bare `uuid` for the same
|
|
815
|
+
// field shape -- before this fix, every one of those 1063 fields failed inlineSchema()
|
|
816
|
+
// closed with `unsupported-format:uuid4` (161 of 568 real request/response schema
|
|
817
|
+
// resolutions attempted against this document failed, 86% of all failures, for this one
|
|
818
|
+
// reason). The RFC 4122 wire format is identical regardless of which UUID version
|
|
819
|
+
// generated the value, so the same BARE_UUID_PATTERN rewrite applies. Deliberately NOT
|
|
820
|
+
// generalized to `uuid1`/`uuid3`/`uuid5` -- no real corpus has measured those yet; add them
|
|
821
|
+
// if and when one does, per this project's own data-first-numerics discipline.
|
|
822
|
+
if (format === 'uuid' || format === 'uuid4') {
|
|
661
823
|
out.pattern = BARE_UUID_PATTERN;
|
|
662
824
|
} else if (typeof format === 'string' && SAFE_FORMATS.has(format)) {
|
|
663
825
|
out.format = format;
|
|
@@ -692,6 +854,33 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
|
|
|
692
854
|
if (serialized !== undefined && serialized !== null && serialized.length <= MAX_EXAMPLE_LENGTH) {
|
|
693
855
|
out.example = node.example;
|
|
694
856
|
}
|
|
857
|
+
} else if (key === 'title') {
|
|
858
|
+
if (typeof node.title === 'string' && node.title.length <= MAX_TITLE_LENGTH) {
|
|
859
|
+
out.title = node.title;
|
|
860
|
+
}
|
|
861
|
+
} else if (key === 'examples') {
|
|
862
|
+
// A14: matches description/example's own "drop the WHOLE field, never partial" doctrine
|
|
863
|
+
// -- an examples array with one oversized entry is dropped entirely, not filtered down
|
|
864
|
+
// to the entries that happened to pass, since a caller reading a shortened array would
|
|
865
|
+
// have no way to tell "this is everything" from "this is what survived a silent cut".
|
|
866
|
+
if (Array.isArray(node.examples) && node.examples.length <= MAX_EXAMPLES_ARRAY_LENGTH) {
|
|
867
|
+
const allFit = node.examples.every((ex) => {
|
|
868
|
+
let serialized;
|
|
869
|
+
try { serialized = JSON.stringify(ex); } catch { serialized = null; }
|
|
870
|
+
return serialized !== undefined && serialized !== null && serialized.length <= MAX_EXAMPLE_LENGTH;
|
|
871
|
+
});
|
|
872
|
+
if (allFit) out.examples = node.examples;
|
|
873
|
+
}
|
|
874
|
+
} else if (key === 'deprecated') {
|
|
875
|
+
if (typeof node.deprecated === 'boolean') {
|
|
876
|
+
out.deprecated = node.deprecated;
|
|
877
|
+
}
|
|
878
|
+
} else if (key === 'readOnly') {
|
|
879
|
+
// A15: identical shape/validation to deprecated -- 2020-12 section 9.4 makes readOnly
|
|
880
|
+
// annotation-only, same as deprecated, so it gets the same boolean-typed copy-or-drop.
|
|
881
|
+
if (typeof node.readOnly === 'boolean') {
|
|
882
|
+
out.readOnly = node.readOnly;
|
|
883
|
+
}
|
|
695
884
|
}
|
|
696
885
|
continue;
|
|
697
886
|
}
|
|
@@ -721,6 +910,31 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
|
|
|
721
910
|
continue;
|
|
722
911
|
}
|
|
723
912
|
|
|
913
|
+
if (key === 'discriminator') {
|
|
914
|
+
// A15 (D-openapi-schema-keyword-recursion): discriminator is annotation-only per the
|
|
915
|
+
// OpenAPI spec -- actual polymorphism validation is done by oneOf/anyOf itself, which
|
|
916
|
+
// this module already walks; discriminator is only a dispatch HINT for consumers, the
|
|
917
|
+
// same validation-inert-but-worth-carrying-through tier as `default` (A7). Real shape
|
|
918
|
+
// measured (polarsource/polar, 36/36 occurrences): always `{propertyName: string,
|
|
919
|
+
// mapping?: {[string]: string}}`, mapping present in all 36. Unlike `pattern`/`required`
|
|
920
|
+
// (real validation constraints, fail the WHOLE schema closed on a malformed value), a
|
|
921
|
+
// malformed discriminator can't silently weaken what the schema validates -- so an
|
|
922
|
+
// off-shape value drops just this field, matching DOCUMENTATION_KEYWORDS' own doctrine,
|
|
923
|
+
// not the fail-closed one. Kept OUTSIDE DOCUMENTATION_KEYWORDS/--descriptions on purpose:
|
|
924
|
+
// this is small structural dispatch metadata, not prose that could bloat contract size,
|
|
925
|
+
// so there's no reason to gate it behind the same flag that exists specifically to bound
|
|
926
|
+
// free-text documentation growth.
|
|
927
|
+
const disc = node.discriminator;
|
|
928
|
+
const propertyNameOk = disc && typeof disc === 'object' && !Array.isArray(disc) && typeof disc.propertyName === 'string';
|
|
929
|
+
const mappingOk = !disc || !Object.hasOwn(disc, 'mapping')
|
|
930
|
+
|| (disc.mapping && typeof disc.mapping === 'object' && !Array.isArray(disc.mapping)
|
|
931
|
+
&& Object.values(disc.mapping).every((v) => typeof v === 'string'));
|
|
932
|
+
if (propertyNameOk && mappingOk) {
|
|
933
|
+
out.discriminator = Object.hasOwn(disc, 'mapping') ? { propertyName: disc.propertyName, mapping: { ...disc.mapping } } : { propertyName: disc.propertyName };
|
|
934
|
+
}
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
|
|
724
938
|
if (COPIED_KEYWORDS.has(key)) {
|
|
725
939
|
if (key === 'enum' && Array.isArray(node.enum)) {
|
|
726
940
|
state.nodes += node.enum.length; // enum entries aren't separate schema nodes, but still cost budget
|
|
@@ -758,7 +972,31 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
|
|
|
758
972
|
continue;
|
|
759
973
|
}
|
|
760
974
|
|
|
761
|
-
if (key === '
|
|
975
|
+
if (key === 'propertyNames') {
|
|
976
|
+
// A15: propertyNames is a REAL validation constraint (restricts what an object's own
|
|
977
|
+
// property NAMES may be) -- unlike discriminator/readOnly, dropping it would silently
|
|
978
|
+
// emit a schema weaker than the real one, the same correctness concern
|
|
979
|
+
// SCHEMA_PROPERTY_NAME_RE already exists to prevent elsewhere. Its value IS a schema, so
|
|
980
|
+
// it recurses exactly like `items`/`additionalProperties`. Real shapes measured
|
|
981
|
+
// (polarsource/polar, 66 occurrences, 3 unique): a plain length-constrained schema
|
|
982
|
+
// (`{maxLength, minLength}`), an already-supported format (`{format: "uuid4"}`), and a
|
|
983
|
+
// real `$ref`. All 3 are ordinary walkSchemaNode() territory already.
|
|
984
|
+
out.propertyNames = walkSchemaNode(node.propertyNames, componentSchemas, depth + 1, visiting, state, limits);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
if (key === 'oneOf' || key === 'anyOf' || key === 'allOf' || key === 'prefixItems') {
|
|
989
|
+
// A16 (D-openapi-schema-keyword-recursion follow-up): `prefixItems` (JSON Schema 2020-12
|
|
990
|
+
// tuple validation) joins this array-of-schemas branch -- unlike `items`/`propertyNames`
|
|
991
|
+
// (a single schema), `prefixItems`' value is an ARRAY of schemas, one per tuple position,
|
|
992
|
+
// the same shape oneOf/anyOf/allOf already have. Real shape measured (polarsource/polar,
|
|
993
|
+
// all 9 occurrences identical): a strict 2-element tuple (`[{type:string},
|
|
994
|
+
// {$ref:.../TaxIDFormat}]`) alongside `type:array`/`maxItems:2`/`minItems:2`/`examples` --
|
|
995
|
+
// all already-handled COPIED_KEYWORDS/DOCUMENTATION_KEYWORDS siblings, nothing new needed
|
|
996
|
+
// for them. `items` never co-occurs with `prefixItems` in this corpus (0/9); if it ever
|
|
997
|
+
// did, both keys are still independently walked and copied here exactly as authored --
|
|
998
|
+
// this module doesn't itself enforce the "items applies beyond the prefix" interaction,
|
|
999
|
+
// it only needs to preserve both fields faithfully for whatever validates the output.
|
|
762
1000
|
const arr = node[key];
|
|
763
1001
|
if (!Array.isArray(arr) || arr.length === 0) fail(`unsupported-keyword:${key}`);
|
|
764
1002
|
out[key] = arr.map((el) => walkSchemaNode(el, componentSchemas, depth + 1, visiting, state, limits));
|
|
@@ -896,7 +1134,34 @@ function projectResponseSchemas(responses, statusRe, componentSchemas, { include
|
|
|
896
1134
|
// verified directly against the installed Ajv2020: a payload matching two overlapping
|
|
897
1135
|
// branches is rejected by oneOf and accepted by anyOf. anyOf states precisely what's true
|
|
898
1136
|
// given the envelope carries no status code: "matches at least one documented shape."
|
|
899
|
-
|
|
1137
|
+
//
|
|
1138
|
+
// D-openapi-cyclic-refs: any distinct[i] may carry its OWN top-level $defs (a cyclic
|
|
1139
|
+
// component reachable from that branch). $ref: "#/$defs/X" resolves against the COMPILED
|
|
1140
|
+
// DOCUMENT's own root, not wherever the branch happens to sit once nested inside this new
|
|
1141
|
+
// `anyOf` wrapper (confirmed live against the installed Ajv2020: leaving $defs on the nested
|
|
1142
|
+
// branch throws "can't resolve reference" once wrapped) -- so every branch's $defs must be
|
|
1143
|
+
// hoisted onto the wrapper's own top-level $defs, stripped from the branch itself. A name
|
|
1144
|
+
// collision with DIFFERENT content across branches is not expected in real data (never
|
|
1145
|
+
// observed) and is genuinely ambiguous which shape a shared name should mean -- fails this
|
|
1146
|
+
// resolution closed rather than silently picking one, the same doctrine A15's discriminator/
|
|
1147
|
+
// propertyNames already apply to a malformed/ambiguous real-world shape.
|
|
1148
|
+
const mergedDefs = {};
|
|
1149
|
+
let defsCollision = false;
|
|
1150
|
+
const branches = distinct.map((schema) => {
|
|
1151
|
+
if (!Object.hasOwn(schema, '$defs')) return schema;
|
|
1152
|
+
const { $defs, ...rest } = schema;
|
|
1153
|
+
for (const [name, body] of Object.entries($defs)) {
|
|
1154
|
+
if (Object.hasOwn(mergedDefs, name) && canonicalJson(mergedDefs[name]) !== canonicalJson(body)) {
|
|
1155
|
+
defsCollision = true;
|
|
1156
|
+
}
|
|
1157
|
+
mergedDefs[name] = body;
|
|
1158
|
+
}
|
|
1159
|
+
return rest;
|
|
1160
|
+
});
|
|
1161
|
+
if (defsCollision) return { outcome: 'unresolved', reason: 'defs-name-collision' };
|
|
1162
|
+
const schema = { anyOf: branches };
|
|
1163
|
+
if (Object.keys(mergedDefs).length > 0) schema.$defs = mergedDefs;
|
|
1164
|
+
return { outcome: 'resolved', schema, sources: distinct.length };
|
|
900
1165
|
}
|
|
901
1166
|
|
|
902
1167
|
// A3: applies both response (2xx) and error (4xx/5xx) projection to a `matched`/`adopted` result,
|
package/contracts/validate.mjs
CHANGED
|
@@ -63,25 +63,44 @@ export function validateEnvelopeStructure(envelope) {
|
|
|
63
63
|
// shape uniform across all three directions ("a named-parts object, additionalProperties:false")
|
|
64
64
|
// -- see D-openapi-response-schema. An unrecognized `direction` also returns null (unconstrained),
|
|
65
65
|
// matching the envelope schema's own enum being the actual gate on valid direction values.
|
|
66
|
+
// D-openapi-cyclic-refs: every direction below NESTS the projected schema one level deeper
|
|
67
|
+
// (`properties.body`) before the caller compiles the WHOLE wrapper with Ajv. `$ref: "#/$defs/X"`
|
|
68
|
+
// is a JSON Pointer resolved against the COMPILED DOCUMENT's own root, not wherever the $ref
|
|
69
|
+
// textually sits -- confirmed live against the installed Ajv2020: leaving a schema's own `$defs`
|
|
70
|
+
// nested at `properties.body.$defs` throws "can't resolve reference #/$defs/X from id #" once
|
|
71
|
+
// wrapped this way, even though the SAME schema compiles and validates correctly on its own
|
|
72
|
+
// (contracts/export.mjs's placement of a schema at a document LEAF isn't affected by this -- only
|
|
73
|
+
// this function's own extra wrapping is). Every call site below hoists a nested `$defs` onto the
|
|
74
|
+
// wrapper's own top level and strips the now-redundant nested copy.
|
|
75
|
+
function hoistDefs(wrapper, schema) {
|
|
76
|
+
if (!schema || !Object.hasOwn(schema, '$defs')) return schema;
|
|
77
|
+
const { $defs, ...rest } = schema;
|
|
78
|
+
wrapper.$defs = $defs;
|
|
79
|
+
return rest;
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
export function operationPayloadSchema(opContract, direction = 'request') {
|
|
67
83
|
if (direction === 'request') {
|
|
68
84
|
const properties = { pathParams: opContract.pathParams };
|
|
69
85
|
const required = ['pathParams'];
|
|
70
86
|
const bodySchema = opContract.requestBodySchema ?? { type: 'object' };
|
|
87
|
+
const result = { type: 'object', additionalProperties: false, properties, required };
|
|
71
88
|
if (opContract.body === true) {
|
|
72
|
-
properties.body = bodySchema;
|
|
89
|
+
properties.body = hoistDefs(result, bodySchema);
|
|
73
90
|
required.push('body');
|
|
74
91
|
} else if (opContract.body === 'unknown') {
|
|
75
|
-
properties.body = bodySchema;
|
|
92
|
+
properties.body = hoistDefs(result, bodySchema);
|
|
76
93
|
}
|
|
77
94
|
// body === false: deliberately absent from `properties` -- with additionalProperties:false
|
|
78
95
|
// below, a payload that includes a body for a known-bodyless operation is rejected outright.
|
|
79
|
-
return
|
|
96
|
+
return result;
|
|
80
97
|
}
|
|
81
98
|
if (direction === 'response' || direction === 'error') {
|
|
82
99
|
const schema = direction === 'response' ? opContract.responseSchema : opContract.errorSchema;
|
|
83
100
|
if (!schema) return null;
|
|
84
|
-
|
|
101
|
+
const result = { type: 'object', additionalProperties: false, properties: {}, required: ['body'] };
|
|
102
|
+
result.properties.body = hoistDefs(result, schema);
|
|
103
|
+
return result;
|
|
85
104
|
}
|
|
86
105
|
return null;
|
|
87
106
|
}
|