@schmock/openapi 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 (46) hide show
  1. package/dist/crud-detector.d.ts +35 -0
  2. package/dist/crud-detector.d.ts.map +1 -0
  3. package/dist/crud-detector.js +153 -0
  4. package/dist/generators.d.ts +14 -0
  5. package/dist/generators.d.ts.map +1 -0
  6. package/dist/generators.js +158 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +221 -0
  10. package/dist/normalizer.d.ts +14 -0
  11. package/dist/normalizer.d.ts.map +1 -0
  12. package/dist/normalizer.js +194 -0
  13. package/dist/parser.d.ts +32 -0
  14. package/dist/parser.d.ts.map +1 -0
  15. package/dist/parser.js +282 -0
  16. package/dist/plugin.d.ts +32 -0
  17. package/dist/plugin.d.ts.map +1 -0
  18. package/dist/plugin.js +129 -0
  19. package/dist/seed.d.ts +15 -0
  20. package/dist/seed.d.ts.map +1 -0
  21. package/dist/seed.js +41 -0
  22. package/package.json +45 -0
  23. package/src/__fixtures__/faker-stress-test.openapi.yaml +1030 -0
  24. package/src/__fixtures__/openapi31.json +34 -0
  25. package/src/__fixtures__/petstore-openapi3.json +168 -0
  26. package/src/__fixtures__/petstore-swagger2.json +141 -0
  27. package/src/__fixtures__/scalar-galaxy.yaml +1314 -0
  28. package/src/__fixtures__/stripe-fixtures3.json +6542 -0
  29. package/src/__fixtures__/stripe-spec3.yaml +161621 -0
  30. package/src/__fixtures__/train-travel.yaml +1264 -0
  31. package/src/crud-detector.test.ts +150 -0
  32. package/src/crud-detector.ts +194 -0
  33. package/src/generators.test.ts +214 -0
  34. package/src/generators.ts +212 -0
  35. package/src/index.ts +4 -0
  36. package/src/normalizer.test.ts +253 -0
  37. package/src/normalizer.ts +233 -0
  38. package/src/parser.test.ts +181 -0
  39. package/src/parser.ts +389 -0
  40. package/src/plugin.test.ts +205 -0
  41. package/src/plugin.ts +185 -0
  42. package/src/seed.ts +62 -0
  43. package/src/steps/openapi-crud.steps.ts +132 -0
  44. package/src/steps/openapi-parsing.steps.ts +111 -0
  45. package/src/steps/openapi-seed.steps.ts +94 -0
  46. package/src/stress.test.ts +2814 -0
@@ -0,0 +1,212 @@
1
+ /// <reference path="../../../types/schmock.d.ts" />
2
+
3
+ import { generateFromSchema } from "@schmock/schema";
4
+ import type { JSONSchema7 } from "json-schema";
5
+ import type { CrudResource } from "./crud-detector.js";
6
+ import type { ParsedPath } from "./parser.js";
7
+
8
+ const COLLECTION_STATE_PREFIX = "openapi:collections:";
9
+
10
+ function isRecord(value: unknown): value is Record<string, unknown> {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+
14
+ function toTuple(status: number, body: unknown): [number, unknown] {
15
+ return [status, body];
16
+ }
17
+
18
+ function collectionKey(resourceName: string): string {
19
+ return `${COLLECTION_STATE_PREFIX}${resourceName}`;
20
+ }
21
+
22
+ function getCollection(
23
+ state: Record<string, unknown>,
24
+ resourceName: string,
25
+ ): unknown[] {
26
+ const key = collectionKey(resourceName);
27
+ if (!Array.isArray(state[key])) {
28
+ state[key] = [];
29
+ }
30
+ const value = state[key];
31
+ if (Array.isArray(value)) {
32
+ return value;
33
+ }
34
+ return [];
35
+ }
36
+
37
+ function getNextId(
38
+ state: Record<string, unknown>,
39
+ resourceName: string,
40
+ ): number {
41
+ const counterKey = `openapi:counter:${resourceName}`;
42
+ const current = state[counterKey];
43
+ const base = typeof current === "number" ? current : 0;
44
+ const next = base + 1;
45
+ state[counterKey] = next;
46
+ return next;
47
+ }
48
+
49
+ export function createListGenerator(
50
+ resource: CrudResource,
51
+ ): Schmock.GeneratorFunction {
52
+ return (ctx: Schmock.RequestContext) => {
53
+ const collection = getCollection(ctx.state, resource.name);
54
+ return [...collection];
55
+ };
56
+ }
57
+
58
+ export function createCreateGenerator(
59
+ resource: CrudResource,
60
+ ): Schmock.GeneratorFunction {
61
+ return (ctx: Schmock.RequestContext) => {
62
+ const collection = getCollection(ctx.state, resource.name);
63
+ const id = getNextId(ctx.state, resource.name);
64
+
65
+ let item: Record<string, unknown>;
66
+ if (isRecord(ctx.body)) {
67
+ item = {
68
+ ...ctx.body,
69
+ [resource.idParam]: id,
70
+ };
71
+ } else {
72
+ item = { [resource.idParam]: id };
73
+ }
74
+
75
+ collection.push(item);
76
+ return toTuple(201, item);
77
+ };
78
+ }
79
+
80
+ export function createReadGenerator(
81
+ resource: CrudResource,
82
+ ): Schmock.GeneratorFunction {
83
+ return (ctx: Schmock.RequestContext) => {
84
+ const collection = getCollection(ctx.state, resource.name);
85
+ const idValue = ctx.params[resource.idParam];
86
+ const item = findById(collection, resource.idParam, idValue);
87
+
88
+ if (!item) {
89
+ return toTuple(404, { error: "Not found", code: "NOT_FOUND" });
90
+ }
91
+
92
+ return item;
93
+ };
94
+ }
95
+
96
+ export function createUpdateGenerator(
97
+ resource: CrudResource,
98
+ ): Schmock.GeneratorFunction {
99
+ return (ctx: Schmock.RequestContext) => {
100
+ const collection = getCollection(ctx.state, resource.name);
101
+ const idValue = ctx.params[resource.idParam];
102
+ const index = findIndexById(collection, resource.idParam, idValue);
103
+
104
+ if (index === -1) {
105
+ return toTuple(404, { error: "Not found", code: "NOT_FOUND" });
106
+ }
107
+
108
+ const existingRaw = collection[index];
109
+ const existing = isRecord(existingRaw) ? existingRaw : {};
110
+ const updated = {
111
+ ...existing,
112
+ ...(isRecord(ctx.body) ? ctx.body : {}),
113
+ [resource.idParam]: existing[resource.idParam], // Preserve ID
114
+ };
115
+ collection[index] = updated;
116
+ return updated;
117
+ };
118
+ }
119
+
120
+ export function createDeleteGenerator(
121
+ resource: CrudResource,
122
+ ): Schmock.GeneratorFunction {
123
+ return (ctx: Schmock.RequestContext) => {
124
+ const collection = getCollection(ctx.state, resource.name);
125
+ const idValue = ctx.params[resource.idParam];
126
+ const index = findIndexById(collection, resource.idParam, idValue);
127
+
128
+ if (index === -1) {
129
+ return toTuple(404, { error: "Not found", code: "NOT_FOUND" });
130
+ }
131
+
132
+ collection.splice(index, 1);
133
+ return toTuple(204, undefined);
134
+ };
135
+ }
136
+
137
+ export function createStaticGenerator(
138
+ parsedPath: ParsedPath,
139
+ ): Schmock.GeneratorFunction {
140
+ // Get the success response schema
141
+ let responseSchema: JSONSchema7 | undefined;
142
+ for (const code of [200, 201]) {
143
+ const resp = parsedPath.responses.get(code);
144
+ if (resp?.schema) {
145
+ responseSchema = resp.schema;
146
+ break;
147
+ }
148
+ }
149
+ if (!responseSchema) {
150
+ for (const [code, resp] of parsedPath.responses) {
151
+ if (code >= 200 && code < 300 && resp.schema) {
152
+ responseSchema = resp.schema;
153
+ break;
154
+ }
155
+ }
156
+ }
157
+
158
+ return () => {
159
+ if (responseSchema) {
160
+ try {
161
+ return generateFromSchema({ schema: responseSchema });
162
+ } catch {
163
+ // Complex schemas (deep anyOf, circular refs) may fail generation.
164
+ // Fall back to an empty object rather than crashing.
165
+ return {};
166
+ }
167
+ }
168
+ return toTuple(200, {});
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Generate seed items for a resource using its schema.
174
+ */
175
+ export function generateSeedItems(
176
+ schema: JSONSchema7,
177
+ count: number,
178
+ idParam: string,
179
+ ): unknown[] {
180
+ const items: unknown[] = [];
181
+ for (let i = 0; i < count; i++) {
182
+ const generated = generateFromSchema({ schema });
183
+ const item: Record<string, unknown> = isRecord(generated)
184
+ ? generated
185
+ : { value: generated };
186
+ item[idParam] = i + 1;
187
+ items.push(item);
188
+ }
189
+ return items;
190
+ }
191
+
192
+ function findById(
193
+ collection: unknown[],
194
+ idParam: string,
195
+ idValue: string,
196
+ ): unknown | undefined {
197
+ return collection.find((item) => {
198
+ if (!isRecord(item)) return false;
199
+ return String(item[idParam]) === String(idValue);
200
+ });
201
+ }
202
+
203
+ function findIndexById(
204
+ collection: unknown[],
205
+ idParam: string,
206
+ idValue: string,
207
+ ): number {
208
+ return collection.findIndex((item) => {
209
+ if (!isRecord(item)) return false;
210
+ return String(item[idParam]) === String(idValue);
211
+ });
212
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ /// <reference path="../../../types/schmock.d.ts" />
2
+
3
+ export type { OpenApiOptions, SeedConfig, SeedSource } from "./plugin.js";
4
+ export { openapi } from "./plugin.js";
@@ -0,0 +1,253 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeSchema } from "./normalizer";
3
+
4
+ describe("normalizeSchema", () => {
5
+ describe("nullable", () => {
6
+ it("converts nullable: true to oneOf with null type", () => {
7
+ const result = normalizeSchema(
8
+ { type: "string", nullable: true },
9
+ "response",
10
+ );
11
+ expect(result).toEqual({
12
+ oneOf: [{ type: "string" }, { type: "null" }],
13
+ });
14
+ });
15
+
16
+ it("removes nullable: false without adding null type", () => {
17
+ const result = normalizeSchema(
18
+ { type: "string", nullable: false },
19
+ "response",
20
+ );
21
+ expect(result).toEqual({ type: "string" });
22
+ });
23
+ });
24
+
25
+ describe("readOnly / writeOnly", () => {
26
+ it("strips readOnly fields from request schemas", () => {
27
+ const result = normalizeSchema(
28
+ {
29
+ type: "object",
30
+ required: ["id", "name"],
31
+ properties: {
32
+ id: { type: "integer", readOnly: true },
33
+ name: { type: "string" },
34
+ },
35
+ },
36
+ "request",
37
+ );
38
+ expect(result.properties).not.toHaveProperty("id");
39
+ expect(result.properties).toHaveProperty("name");
40
+ expect(result.required).toEqual(["name"]);
41
+ });
42
+
43
+ it("keeps readOnly fields in response schemas", () => {
44
+ const result = normalizeSchema(
45
+ {
46
+ type: "object",
47
+ properties: {
48
+ id: { type: "integer", readOnly: true },
49
+ name: { type: "string" },
50
+ },
51
+ },
52
+ "response",
53
+ );
54
+ expect(result.properties).toHaveProperty("id");
55
+ expect(result.properties).toHaveProperty("name");
56
+ });
57
+
58
+ it("strips writeOnly fields from response schemas", () => {
59
+ const result = normalizeSchema(
60
+ {
61
+ type: "object",
62
+ properties: {
63
+ password: { type: "string", writeOnly: true },
64
+ name: { type: "string" },
65
+ },
66
+ },
67
+ "response",
68
+ );
69
+ expect(result.properties).not.toHaveProperty("password");
70
+ expect(result.properties).toHaveProperty("name");
71
+ });
72
+
73
+ it("keeps writeOnly fields in request schemas", () => {
74
+ const result = normalizeSchema(
75
+ {
76
+ type: "object",
77
+ properties: {
78
+ password: { type: "string", writeOnly: true },
79
+ name: { type: "string" },
80
+ },
81
+ },
82
+ "request",
83
+ );
84
+ expect(result.properties).toHaveProperty("password");
85
+ });
86
+ });
87
+
88
+ describe("example → default", () => {
89
+ it("copies example to default when default is not set", () => {
90
+ const result = normalizeSchema(
91
+ { type: "string", example: "hello" },
92
+ "response",
93
+ );
94
+ expect(result.default).toBe("hello");
95
+ expect(result).not.toHaveProperty("example");
96
+ });
97
+
98
+ it("preserves existing default when example is present", () => {
99
+ const result = normalizeSchema(
100
+ { type: "string", example: "hello", default: "world" },
101
+ "response",
102
+ );
103
+ expect(result.default).toBe("world");
104
+ });
105
+ });
106
+
107
+ describe("exclusiveMinimum/exclusiveMaximum boolean → number", () => {
108
+ it("converts exclusiveMinimum: true to number format", () => {
109
+ const result = normalizeSchema(
110
+ { type: "number", minimum: 0, exclusiveMinimum: true },
111
+ "response",
112
+ );
113
+ expect(result.exclusiveMinimum).toBe(0);
114
+ expect(result).not.toHaveProperty("minimum");
115
+ });
116
+
117
+ it("removes exclusiveMinimum: false", () => {
118
+ const result = normalizeSchema(
119
+ { type: "number", minimum: 0, exclusiveMinimum: false },
120
+ "response",
121
+ );
122
+ expect(result.minimum).toBe(0);
123
+ expect(result).not.toHaveProperty("exclusiveMinimum");
124
+ });
125
+
126
+ it("converts exclusiveMaximum: true to number format", () => {
127
+ const result = normalizeSchema(
128
+ { type: "number", maximum: 100, exclusiveMaximum: true },
129
+ "response",
130
+ );
131
+ expect(result.exclusiveMaximum).toBe(100);
132
+ expect(result).not.toHaveProperty("maximum");
133
+ });
134
+ });
135
+
136
+ describe("x-* extensions", () => {
137
+ it("strips all x- prefixed properties", () => {
138
+ const result = normalizeSchema(
139
+ {
140
+ type: "string",
141
+ "x-custom": "value",
142
+ "x-another": 42,
143
+ },
144
+ "response",
145
+ );
146
+ expect(result).not.toHaveProperty("x-custom");
147
+ expect(result).not.toHaveProperty("x-another");
148
+ expect(result.type).toBe("string");
149
+ });
150
+ });
151
+
152
+ describe("deep recursion", () => {
153
+ it("normalizes nested properties → items → oneOf", () => {
154
+ const result = normalizeSchema(
155
+ {
156
+ type: "object",
157
+ properties: {
158
+ items: {
159
+ type: "array",
160
+ items: {
161
+ oneOf: [
162
+ { type: "string", nullable: true },
163
+ { type: "integer", example: 42 },
164
+ ],
165
+ },
166
+ },
167
+ },
168
+ },
169
+ "response",
170
+ );
171
+
172
+ const itemsSchema = result.properties?.items;
173
+ expect(itemsSchema).toBeDefined();
174
+ const arraySchema = itemsSchema as Record<string, unknown>;
175
+ const itemSchema = arraySchema.items as Record<string, unknown>;
176
+ const branches = itemSchema.oneOf as Record<string, unknown>[];
177
+ expect(branches).toHaveLength(2);
178
+ // First branch: nullable string → oneOf
179
+ expect(branches[0]).toHaveProperty("oneOf");
180
+ // Second branch: example → default
181
+ expect(branches[1]).toHaveProperty("default", 42);
182
+ });
183
+ });
184
+
185
+ describe("passthrough", () => {
186
+ it("preserves standard JSON Schema 7 features", () => {
187
+ const input = {
188
+ type: "object",
189
+ properties: {
190
+ name: { type: "string", minLength: 1, maxLength: 100 },
191
+ age: { type: "integer", minimum: 0, maximum: 150 },
192
+ role: { type: "string", enum: ["admin", "user"] },
193
+ email: { type: "string", pattern: "^[^@]+@[^@]+$" },
194
+ },
195
+ required: ["name"],
196
+ };
197
+ const result = normalizeSchema(input, "response");
198
+ expect(result.properties?.name).toMatchObject({
199
+ type: "string",
200
+ minLength: 1,
201
+ maxLength: 100,
202
+ });
203
+ expect(result.properties?.age).toMatchObject({
204
+ type: "integer",
205
+ minimum: 0,
206
+ maximum: 150,
207
+ });
208
+ expect(result.required).toEqual(["name"]);
209
+ });
210
+ });
211
+
212
+ describe("discriminator", () => {
213
+ it("adds required and enum constraints for discriminator", () => {
214
+ const result = normalizeSchema(
215
+ {
216
+ discriminator: {
217
+ propertyName: "petType",
218
+ mapping: {
219
+ dog: "#/components/schemas/Dog",
220
+ cat: "#/components/schemas/Cat",
221
+ },
222
+ },
223
+ oneOf: [
224
+ {
225
+ type: "object",
226
+ properties: {
227
+ petType: { type: "string" },
228
+ bark: { type: "boolean" },
229
+ },
230
+ },
231
+ {
232
+ type: "object",
233
+ properties: {
234
+ petType: { type: "string" },
235
+ purr: { type: "boolean" },
236
+ },
237
+ },
238
+ ],
239
+ },
240
+ "response",
241
+ );
242
+
243
+ expect(result).not.toHaveProperty("discriminator");
244
+ const branches = result.oneOf as Record<string, unknown>[];
245
+ expect(branches).toHaveLength(2);
246
+
247
+ // Each branch should have petType as required
248
+ for (const branch of branches) {
249
+ expect(branch.required).toContain("petType");
250
+ }
251
+ });
252
+ });
253
+ });
@@ -0,0 +1,233 @@
1
+ /// <reference path="../../../types/schmock.d.ts" />
2
+
3
+ import type { JSONSchema7 } from "json-schema";
4
+
5
+ function isRecord(value: unknown): value is Record<string, unknown> {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+
9
+ function toJsonSchema(node: Record<string, unknown>): JSONSchema7 {
10
+ // Object.assign merges unknown-keyed properties into JSONSchema7.
11
+ // This is safe because the normalizer has already ensured the shape
12
+ // is valid JSON Schema 7 before calling this function.
13
+ return Object.assign<JSONSchema7, Record<string, unknown>>({}, node);
14
+ }
15
+
16
+ /**
17
+ * Normalize an OpenAPI schema to pure JSON Schema 7 that json-schema-faker understands.
18
+ *
19
+ * Transforms applied:
20
+ * - nullable: true -> oneOf with null type
21
+ * - discriminator -> required + enum on branches
22
+ * - readOnly/writeOnly -> strip based on direction
23
+ * - example -> default (if default not set)
24
+ * - exclusiveMinimum/exclusiveMaximum boolean -> number format
25
+ * - x-* extensions -> stripped
26
+ */
27
+ export function normalizeSchema(
28
+ schema: Record<string, unknown>,
29
+ direction: "request" | "response",
30
+ ): JSONSchema7 {
31
+ return normalizeNode(structuredClone(schema), direction, new WeakSet());
32
+ }
33
+
34
+ function normalizeNode(
35
+ node: Record<string, unknown>,
36
+ direction: "request" | "response",
37
+ visited: WeakSet<object>,
38
+ ): JSONSchema7 {
39
+ if (!node || typeof node !== "object" || Array.isArray(node)) {
40
+ return toJsonSchema({});
41
+ }
42
+
43
+ // Circular reference detection — break cycles
44
+ if (visited.has(node)) {
45
+ return toJsonSchema({});
46
+ }
47
+ visited.add(node);
48
+
49
+ // Strip x-* extensions
50
+ for (const key of Object.keys(node)) {
51
+ if (key.startsWith("x-")) {
52
+ delete node[key];
53
+ }
54
+ }
55
+
56
+ // Handle nullable: true -> oneOf with null
57
+ if (node.nullable === true) {
58
+ delete node.nullable;
59
+ // Only wrap if not already a composition with null
60
+ const copy = { ...node };
61
+ delete copy.nullable;
62
+ return {
63
+ oneOf: [normalizeNode(copy, direction, visited), { type: "null" }],
64
+ };
65
+ }
66
+ delete node.nullable;
67
+
68
+ // Handle discriminator
69
+ if (node.discriminator && isRecord(node.discriminator)) {
70
+ const disc = node.discriminator;
71
+ const propName = disc.propertyName;
72
+ if (typeof propName === "string" && Array.isArray(node.oneOf)) {
73
+ const mappingRaw = disc.mapping ?? {};
74
+ const mapping = isRecord(mappingRaw) ? mappingRaw : {};
75
+ // mapping keys ARE the discriminator values (e.g. "dog", "cat")
76
+ // mapping values are $ref strings — after dereference they can't be matched to branches
77
+ // Use key order to correspond to oneOf branch order
78
+ const discriminatorValues = Object.keys(mapping);
79
+
80
+ node.oneOf = node.oneOf
81
+ .filter((branch): branch is Record<string, unknown> => isRecord(branch))
82
+ .map((branch, index) => {
83
+ const normalized = normalizeNode(branch, direction, visited);
84
+ // Ensure discriminator property is required
85
+ if (isRecord(normalized)) {
86
+ const required = Array.isArray(normalized.required)
87
+ ? [...normalized.required]
88
+ : [];
89
+ if (!required.includes(propName)) {
90
+ required.push(propName);
91
+ }
92
+ normalized.required = required;
93
+
94
+ // Add enum constraint for the discriminator value
95
+ const mappingValue = discriminatorValues[index];
96
+ if (mappingValue && isRecord(normalized.properties)) {
97
+ const props = normalized.properties;
98
+ const existingRaw = props[propName] ?? {};
99
+ const existing = isRecord(existingRaw) ? existingRaw : {};
100
+ props[propName] = { ...existing, enum: [mappingValue] };
101
+ }
102
+ }
103
+ return normalized;
104
+ });
105
+ }
106
+ delete node.discriminator;
107
+ }
108
+
109
+ // Handle readOnly/writeOnly on properties
110
+ if (isRecord(node.properties)) {
111
+ const props = node.properties;
112
+ const required = Array.isArray(node.required)
113
+ ? node.required.filter((r): r is string => typeof r === "string")
114
+ : [];
115
+ const keysToRemove: string[] = [];
116
+
117
+ for (const [propName, propSchemaRaw] of Object.entries(props)) {
118
+ if (!isRecord(propSchemaRaw)) continue;
119
+ const propSchema = propSchemaRaw;
120
+
121
+ // readOnly fields: remove from request schemas
122
+ if (direction === "request" && propSchema.readOnly === true) {
123
+ keysToRemove.push(propName);
124
+ continue;
125
+ }
126
+ // writeOnly fields: remove from response schemas
127
+ if (direction === "response" && propSchema.writeOnly === true) {
128
+ keysToRemove.push(propName);
129
+ continue;
130
+ }
131
+
132
+ // Clean up the flags after handling
133
+ delete propSchema.readOnly;
134
+ delete propSchema.writeOnly;
135
+
136
+ // Recurse into property
137
+ props[propName] = normalizeNode(propSchema, direction, visited);
138
+ }
139
+
140
+ for (const key of keysToRemove) {
141
+ delete props[key];
142
+ const reqIdx = required.indexOf(key);
143
+ if (reqIdx !== -1) {
144
+ required.splice(reqIdx, 1);
145
+ }
146
+ }
147
+
148
+ if (required.length > 0) {
149
+ node.required = required;
150
+ } else if (keysToRemove.length > 0 && Array.isArray(node.required)) {
151
+ // If we removed all required fields, clean up
152
+ if (required.length === 0) {
153
+ delete node.required;
154
+ }
155
+ }
156
+ }
157
+
158
+ // Handle example -> default
159
+ if ("example" in node && !("default" in node)) {
160
+ node.default = node.example;
161
+ }
162
+ delete node.example;
163
+
164
+ // Handle exclusiveMinimum/exclusiveMaximum boolean -> number
165
+ if (node.exclusiveMinimum === true && typeof node.minimum === "number") {
166
+ node.exclusiveMinimum = node.minimum;
167
+ delete node.minimum;
168
+ } else if (node.exclusiveMinimum === false) {
169
+ delete node.exclusiveMinimum;
170
+ }
171
+
172
+ if (node.exclusiveMaximum === true && typeof node.maximum === "number") {
173
+ node.exclusiveMaximum = node.maximum;
174
+ delete node.maximum;
175
+ } else if (node.exclusiveMaximum === false) {
176
+ delete node.exclusiveMaximum;
177
+ }
178
+
179
+ // Recurse into items (array schema)
180
+ if (node.items) {
181
+ if (Array.isArray(node.items)) {
182
+ node.items = node.items.map((item: unknown) =>
183
+ isRecord(item) ? normalizeNode(item, direction, visited) : item,
184
+ );
185
+ } else if (isRecord(node.items)) {
186
+ node.items = normalizeNode(node.items, direction, visited);
187
+ }
188
+ }
189
+
190
+ // Recurse into additionalProperties
191
+ if (isRecord(node.additionalProperties)) {
192
+ node.additionalProperties = normalizeNode(
193
+ node.additionalProperties,
194
+ direction,
195
+ visited,
196
+ );
197
+ }
198
+
199
+ // Recurse into composition keywords
200
+ for (const keyword of ["allOf", "anyOf", "oneOf"]) {
201
+ const keywordValue = node[keyword];
202
+ if (Array.isArray(keywordValue)) {
203
+ node[keyword] = keywordValue.map((branch: unknown) =>
204
+ isRecord(branch) ? normalizeNode(branch, direction, visited) : branch,
205
+ );
206
+ }
207
+ }
208
+
209
+ // Recurse into not
210
+ if (isRecord(node.not)) {
211
+ node.not = normalizeNode(node.not, direction, visited);
212
+ }
213
+
214
+ // Recurse into conditional
215
+ for (const keyword of ["if", "then", "else"]) {
216
+ const keywordValue = node[keyword];
217
+ if (isRecord(keywordValue)) {
218
+ node[keyword] = normalizeNode(keywordValue, direction, visited);
219
+ }
220
+ }
221
+
222
+ // Recurse into patternProperties
223
+ if (isRecord(node.patternProperties)) {
224
+ const pp = node.patternProperties;
225
+ for (const [pattern, schema] of Object.entries(pp)) {
226
+ if (isRecord(schema)) {
227
+ pp[pattern] = normalizeNode(schema, direction, visited);
228
+ }
229
+ }
230
+ }
231
+
232
+ return toJsonSchema(node);
233
+ }