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.
Files changed (48) hide show
  1. package/README.md +66 -4
  2. package/bin/bskel.mjs +125 -18
  3. package/contracts/export.mjs +39 -4
  4. package/contracts/openapi.mjs +292 -27
  5. package/contracts/validate.mjs +23 -4
  6. package/handles/_engine.mjs +75 -32
  7. package/handles/capability-codec.mjs +94 -0
  8. package/handles/codec.mjs +13 -3
  9. package/handles/providers/java-spring/emit.mjs +78 -33
  10. package/handles/providers/java-spring/observe.mjs +4 -3
  11. package/handles/providers/java-spring/plan.mjs +51 -7
  12. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
  13. package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
  14. package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
  15. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
  16. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
  17. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
  18. package/handles/providers/java-spring.mjs +8 -0
  19. package/handles/providers/python-fastapi/emit.mjs +21 -26
  20. package/handles/providers/python-fastapi/observe.mjs +6 -5
  21. package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
  22. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
  23. package/handles/providers/python-fastapi.mjs +3 -3
  24. package/handles/providers/typescript-express/emit.mjs +135 -46
  25. package/handles/providers/typescript-express/observe.mjs +7 -6
  26. package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
  27. package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
  28. package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
  29. package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
  30. package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
  31. package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
  32. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
  33. package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
  34. package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
  35. package/handles/providers/typescript-express.mjs +7 -4
  36. package/lib/cli.mjs +11 -2
  37. package/lib/exit-codes.mjs +21 -0
  38. package/lib/verify.mjs +23 -6
  39. package/package.json +5 -2
  40. package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
  41. package/scanners/adapters/java-spring.mjs +108 -10
  42. package/scanners/adapters/javascript-express.mjs +46 -13
  43. package/scanners/adapters/typescript-express.mjs +13 -2
  44. package/schemas/feature-contract.schema.json +3 -3
  45. package/schemas/handles-plan.schema.json +2 -0
  46. package/schemas/oracle-manifest.schema.json +58 -0
  47. package/schemas/stack-record.schema.json +6 -1
  48. package/stack/apply.mjs +47 -6
@@ -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
- const MAX_SCHEMA_NODES = 2000; // shared counter per top-level inlineSchema() call, enum entries count
32
- const MAX_PATTERN_LENGTH = 300; // real max observed: 77
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
- export const SCHEMA_PROPERTY_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
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
- const RECURSED_KEYWORDS = Object.freeze(new Set(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf']));
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. `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']));
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` set inlineSchema() itself
191
- // recurses through) -- NOT a blanket "every key anywhere in the document" scan. That distinction
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
- const SAFE_FORMATS = Object.freeze(new Set(['int32', 'int64', 'email', 'date', 'date-time', 'double', 'float', 'binary', 'uri']));
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 with NO `$ref` anywhere in the output. Pure, and NEVER
577
- // throws across this exported boundary (InlineFailure is caught here, anything else re-thrown --
578
- // it would be a real programming bug, not an untrusted-input failure, and must not be swallowed).
579
- // Full inlining (never registering a component with ajv by $id) for two independent reasons: ajv
580
- // would otherwise need every one of a document's component schemas registered just to validate
581
- // ONE operation's body, and bin/bskel.mjs's cmdContractToolSchema promises its `input_schema`
582
- // output is a JSON Schema subset "directly usable as-is" for Anthropic tool-use -- no $ref/$defs
583
- // is exactly what that promise requires; this function is what upholds it.
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)) fail('cycle-detected');
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
- if (format === 'uuid') {
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 === 'oneOf' || key === 'anyOf' || key === 'allOf') {
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
- return { outcome: 'resolved', schema: { anyOf: distinct }, sources: distinct.length };
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,
@@ -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 { type: 'object', additionalProperties: false, properties, required };
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
- return { type: 'object', additionalProperties: false, properties: { body: schema }, required: ['body'] };
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
  }