@hey-api/json-schema-ref-parser 1.0.8 → 1.2.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/lib/bundle.ts CHANGED
@@ -1,5 +1,3 @@
1
- import isEqual from "lodash/isEqual";
2
-
3
1
  import $Ref from "./ref.js";
4
2
  import type { ParserOptions } from "./options.js";
5
3
  import Pointer from "./pointer.js";
@@ -8,6 +6,17 @@ import type $Refs from "./refs.js";
8
6
  import type { $RefParser } from "./index";
9
7
  import type { JSONSchema } from "./types/index.js";
10
8
 
9
+ const DEBUG_PERFORMANCE =
10
+ process.env.DEBUG === "true" ||
11
+ (typeof globalThis !== "undefined" && (globalThis as any).DEBUG_BUNDLE_PERFORMANCE === true);
12
+
13
+ const perf = {
14
+ mark: (name: string) => DEBUG_PERFORMANCE && performance.mark(name),
15
+ measure: (name: string, start: string, end: string) => DEBUG_PERFORMANCE && performance.measure(name, start, end),
16
+ log: (message: string, ...args: any[]) => DEBUG_PERFORMANCE && console.log("[PERF] " + message, ...args),
17
+ warn: (message: string, ...args: any[]) => DEBUG_PERFORMANCE && console.warn("[PERF] " + message, ...args),
18
+ };
19
+
11
20
  export interface InventoryEntry {
12
21
  $ref: any;
13
22
  circular: any;
@@ -21,22 +30,87 @@ export interface InventoryEntry {
21
30
  parent: any;
22
31
  pathFromRoot: any;
23
32
  value: any;
33
+ originalContainerType?: "schemas" | "parameters" | "requestBodies" | "responses" | "headers";
24
34
  }
25
35
 
26
36
  /**
27
- * TODO
37
+ * Fast lookup using Map instead of linear search with deep equality
28
38
  */
29
- const findInInventory = (inventory: Array<InventoryEntry>, $refParent: any, $refKey: any) => {
30
- for (const entry of inventory) {
31
- if (entry) {
32
- if (isEqual(entry.parent, $refParent)) {
33
- if (entry.key === $refKey) {
34
- return entry;
35
- }
36
- }
39
+ const createInventoryLookup = () => {
40
+ const lookup = new Map<string, InventoryEntry>();
41
+ const objectIds = new WeakMap<object, string>(); // Use WeakMap to avoid polluting objects
42
+ let idCounter = 0;
43
+ let lookupCount = 0;
44
+ let addCount = 0;
45
+
46
+ const getObjectId = (obj: any) => {
47
+ if (!objectIds.has(obj)) {
48
+ objectIds.set(obj, `obj_${++idCounter}`);
37
49
  }
50
+ return objectIds.get(obj)!;
51
+ };
52
+
53
+ const createInventoryKey = ($refParent: any, $refKey: any) => {
54
+ // Use WeakMap-based lookup to avoid polluting the actual schema objects
55
+ return `${getObjectId($refParent)}_${$refKey}`;
56
+ };
57
+
58
+ return {
59
+ add: (entry: InventoryEntry) => {
60
+ addCount++;
61
+ const key = createInventoryKey(entry.parent, entry.key);
62
+ lookup.set(key, entry);
63
+ if (addCount % 100 === 0) {
64
+ perf.log(`Inventory lookup: Added ${addCount} entries, map size: ${lookup.size}`);
65
+ }
66
+ },
67
+ find: ($refParent: any, $refKey: any) => {
68
+ lookupCount++;
69
+ const key = createInventoryKey($refParent, $refKey);
70
+ const result = lookup.get(key);
71
+ if (lookupCount % 100 === 0) {
72
+ perf.log(`Inventory lookup: ${lookupCount} lookups performed`);
73
+ }
74
+ return result;
75
+ },
76
+ remove: (entry: InventoryEntry) => {
77
+ const key = createInventoryKey(entry.parent, entry.key);
78
+ lookup.delete(key);
79
+ },
80
+ getStats: () => ({ lookupCount, addCount, mapSize: lookup.size }),
81
+ };
82
+ };
83
+
84
+ /**
85
+ * Determine the container type from a JSON Pointer path.
86
+ * Analyzes the path tokens to identify the appropriate OpenAPI component container.
87
+ *
88
+ * @param path - The JSON Pointer path to analyze
89
+ * @returns The container type: "schemas", "parameters", "requestBodies", "responses", or "headers"
90
+ */
91
+ const getContainerTypeFromPath = (
92
+ path: string,
93
+ ): "schemas" | "parameters" | "requestBodies" | "responses" | "headers" => {
94
+ const tokens = Pointer.parse(path);
95
+ const has = (t: string) => tokens.includes(t);
96
+ // Prefer more specific containers first
97
+ if (has("parameters")) {
98
+ return "parameters";
99
+ }
100
+ if (has("requestBody")) {
101
+ return "requestBodies";
102
+ }
103
+ if (has("headers")) {
104
+ return "headers";
38
105
  }
39
- return undefined;
106
+ if (has("responses")) {
107
+ return "responses";
108
+ }
109
+ if (has("schema")) {
110
+ return "schemas";
111
+ }
112
+ // default: treat as schema-like
113
+ return "schemas";
40
114
  };
41
115
 
42
116
  /**
@@ -49,9 +123,12 @@ const inventory$Ref = <S extends object = JSONSchema>({
49
123
  $refs,
50
124
  indirections,
51
125
  inventory,
126
+ inventoryLookup,
52
127
  options,
53
128
  path,
54
129
  pathFromRoot,
130
+ visitedObjects = new WeakSet(),
131
+ resolvedRefs = new Map(),
55
132
  }: {
56
133
  /**
57
134
  * The key in `$refParent` that is a JSON Reference
@@ -70,6 +147,10 @@ const inventory$Ref = <S extends object = JSONSchema>({
70
147
  * An array of already-inventoried $ref pointers
71
148
  */
72
149
  inventory: Array<InventoryEntry>;
150
+ /**
151
+ * Fast lookup for inventory entries
152
+ */
153
+ inventoryLookup: ReturnType<typeof createInventoryLookup>;
73
154
  options: ParserOptions;
74
155
  /**
75
156
  * The full path of the JSON Reference at `$refKey`, possibly with a JSON Pointer in the hash
@@ -79,13 +160,39 @@ const inventory$Ref = <S extends object = JSONSchema>({
79
160
  * The path of the JSON Reference at `$refKey`, from the schema root
80
161
  */
81
162
  pathFromRoot: string;
163
+ /**
164
+ * Set of already visited objects to avoid infinite loops and redundant processing
165
+ */
166
+ visitedObjects?: WeakSet<object>;
167
+ /**
168
+ * Cache for resolved $ref targets to avoid redundant resolution
169
+ */
170
+ resolvedRefs?: Map<string, any>;
82
171
  }) => {
172
+ perf.mark("inventory-ref-start");
83
173
  const $ref = $refKey === null ? $refParent : $refParent[$refKey];
84
174
  const $refPath = url.resolve(path, $ref.$ref);
85
- const pointer = $refs._resolve($refPath, pathFromRoot, options);
175
+
176
+ // Check cache first to avoid redundant resolution
177
+ let pointer = resolvedRefs.get($refPath);
178
+ if (!pointer) {
179
+ perf.mark("resolve-start");
180
+ pointer = $refs._resolve($refPath, pathFromRoot, options);
181
+ perf.mark("resolve-end");
182
+ perf.measure("resolve-time", "resolve-start", "resolve-end");
183
+
184
+ if (pointer) {
185
+ resolvedRefs.set($refPath, pointer);
186
+ perf.log(`Cached resolved $ref: ${$refPath}`);
187
+ }
188
+ }
189
+
86
190
  if (pointer === null) {
191
+ perf.mark("inventory-ref-end");
192
+ perf.measure("inventory-ref-time", "inventory-ref-start", "inventory-ref-end");
87
193
  return;
88
194
  }
195
+
89
196
  const parsed = Pointer.parse(pathFromRoot);
90
197
  const depth = parsed.length;
91
198
  const file = url.stripHash(pointer.path);
@@ -95,17 +202,24 @@ const inventory$Ref = <S extends object = JSONSchema>({
95
202
  indirections += pointer.indirections;
96
203
 
97
204
  // Check if this exact location (parent + key + pathFromRoot) has already been inventoried
98
- const existingEntry = findInInventory(inventory, $refParent, $refKey);
205
+ perf.mark("lookup-start");
206
+ const existingEntry = inventoryLookup.find($refParent, $refKey);
207
+ perf.mark("lookup-end");
208
+ perf.measure("lookup-time", "lookup-start", "lookup-end");
209
+
99
210
  if (existingEntry && existingEntry.pathFromRoot === pathFromRoot) {
100
211
  // This exact location has already been inventoried, so we don't need to process it again
101
212
  if (depth < existingEntry.depth || indirections < existingEntry.indirections) {
102
213
  removeFromInventory(inventory, existingEntry);
214
+ inventoryLookup.remove(existingEntry);
103
215
  } else {
216
+ perf.mark("inventory-ref-end");
217
+ perf.measure("inventory-ref-time", "inventory-ref-start", "inventory-ref-end");
104
218
  return;
105
219
  }
106
220
  }
107
221
 
108
- inventory.push({
222
+ const newEntry: InventoryEntry = {
109
223
  $ref, // The JSON Reference (e.g. {$ref: string})
110
224
  circular: pointer.circular, // Is this $ref pointer DIRECTLY circular? (i.e. it references itself)
111
225
  depth, // How far from the JSON Schema root is this $ref pointer?
@@ -118,10 +232,17 @@ const inventory$Ref = <S extends object = JSONSchema>({
118
232
  parent: $refParent, // The object that contains this $ref pointer
119
233
  pathFromRoot, // The path to the $ref pointer, from the JSON Schema root
120
234
  value: pointer.value, // The resolved value of the $ref pointer
121
- });
235
+ originalContainerType: external ? getContainerTypeFromPath(pointer.path) : undefined, // The original container type in the external file
236
+ };
237
+
238
+ inventory.push(newEntry);
239
+ inventoryLookup.add(newEntry);
240
+
241
+ perf.log(`Inventoried $ref: ${$ref.$ref} -> ${file}${hash} (external: ${external}, depth: ${depth})`);
122
242
 
123
243
  // Recursively crawl the resolved value
124
244
  if (!existingEntry || external) {
245
+ perf.mark("crawl-recursive-start");
125
246
  crawl({
126
247
  parent: pointer.value,
127
248
  key: null,
@@ -129,10 +250,18 @@ const inventory$Ref = <S extends object = JSONSchema>({
129
250
  pathFromRoot,
130
251
  indirections: indirections + 1,
131
252
  inventory,
253
+ inventoryLookup,
132
254
  $refs,
133
255
  options,
256
+ visitedObjects,
257
+ resolvedRefs,
134
258
  });
259
+ perf.mark("crawl-recursive-end");
260
+ perf.measure("crawl-recursive-time", "crawl-recursive-start", "crawl-recursive-end");
135
261
  }
262
+
263
+ perf.mark("inventory-ref-end");
264
+ perf.measure("inventory-ref-time", "inventory-ref-start", "inventory-ref-end");
136
265
  };
137
266
 
138
267
  /**
@@ -142,11 +271,14 @@ const crawl = <S extends object = JSONSchema>({
142
271
  $refs,
143
272
  indirections,
144
273
  inventory,
274
+ inventoryLookup,
145
275
  key,
146
276
  options,
147
277
  parent,
148
278
  path,
149
279
  pathFromRoot,
280
+ visitedObjects = new WeakSet(),
281
+ resolvedRefs = new Map(),
150
282
  }: {
151
283
  $refs: $Refs<S>;
152
284
  indirections: number;
@@ -154,6 +286,10 @@ const crawl = <S extends object = JSONSchema>({
154
286
  * An array of already-inventoried $ref pointers
155
287
  */
156
288
  inventory: Array<InventoryEntry>;
289
+ /**
290
+ * Fast lookup for inventory entries
291
+ */
292
+ inventoryLookup: ReturnType<typeof createInventoryLookup>;
157
293
  /**
158
294
  * The property key of `parent` to be crawled
159
295
  */
@@ -171,11 +307,26 @@ const crawl = <S extends object = JSONSchema>({
171
307
  * The path of the property being crawled, from the schema root
172
308
  */
173
309
  pathFromRoot: string;
310
+ /**
311
+ * Set of already visited objects to avoid infinite loops and redundant processing
312
+ */
313
+ visitedObjects?: WeakSet<object>;
314
+ /**
315
+ * Cache for resolved $ref targets to avoid redundant resolution
316
+ */
317
+ resolvedRefs?: Map<string, any>;
174
318
  }) => {
175
319
  const obj = key === null ? parent : parent[key as keyof typeof parent];
176
320
 
177
321
  if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj)) {
322
+ // Early exit if we've already processed this exact object
323
+ if (visitedObjects.has(obj)) {
324
+ perf.log(`Skipping already visited object at ${pathFromRoot}`);
325
+ return;
326
+ }
327
+
178
328
  if ($Ref.isAllowed$Ref(obj)) {
329
+ perf.log(`Found $ref at ${pathFromRoot}: ${(obj as any).$ref}`);
179
330
  inventory$Ref({
180
331
  $refParent: parent,
181
332
  $refKey: key,
@@ -183,10 +334,16 @@ const crawl = <S extends object = JSONSchema>({
183
334
  pathFromRoot,
184
335
  indirections,
185
336
  inventory,
337
+ inventoryLookup,
186
338
  $refs,
187
339
  options,
340
+ visitedObjects,
341
+ resolvedRefs,
188
342
  });
189
343
  } else {
344
+ // Mark this object as visited BEFORE processing its children
345
+ visitedObjects.add(obj);
346
+
190
347
  // Crawl the object in a specific order that's optimized for bundling.
191
348
  // This is important because it determines how `pathFromRoot` gets built,
192
349
  // which later determines which keys get dereferenced and which ones get remapped
@@ -217,8 +374,11 @@ const crawl = <S extends object = JSONSchema>({
217
374
  pathFromRoot: keyPathFromRoot,
218
375
  indirections,
219
376
  inventory,
377
+ inventoryLookup,
220
378
  $refs,
221
379
  options,
380
+ visitedObjects,
381
+ resolvedRefs,
222
382
  });
223
383
  } else {
224
384
  crawl({
@@ -228,8 +388,11 @@ const crawl = <S extends object = JSONSchema>({
228
388
  pathFromRoot: keyPathFromRoot,
229
389
  indirections,
230
390
  inventory,
391
+ inventoryLookup,
231
392
  $refs,
232
393
  options,
394
+ visitedObjects,
395
+ resolvedRefs,
233
396
  });
234
397
  }
235
398
  }
@@ -238,29 +401,16 @@ const crawl = <S extends object = JSONSchema>({
238
401
  };
239
402
 
240
403
  /**
241
- * Re-maps every $ref pointer, so that they're all relative to the root of the JSON Schema.
242
- * Each referenced value is dereferenced EXACTLY ONCE. All subsequent references to the same
243
- * value are re-mapped to point to the first reference.
244
- *
245
- * @example: {
246
- * first: { $ref: somefile.json#/some/part },
247
- * second: { $ref: somefile.json#/another/part },
248
- * third: { $ref: somefile.json },
249
- * fourth: { $ref: somefile.json#/some/part/sub/part }
250
- * }
251
- *
252
- * In this example, there are four references to the same file, but since the third reference points
253
- * to the ENTIRE file, that's the only one we need to dereference. The other three can just be
254
- * remapped to point inside the third one.
255
- *
256
- * On the other hand, if the third reference DIDN'T exist, then the first and second would both need
257
- * to be dereferenced, since they point to different parts of the file. The fourth reference does NOT
258
- * need to be dereferenced, because it can be remapped to point inside the first one.
259
- *
260
- * @param inventory
404
+ * Remap external refs by hoisting resolved values into a shared container in the root schema
405
+ * and pointing all occurrences to those internal definitions. Internal refs remain internal.
261
406
  */
262
- function remap(inventory: InventoryEntry[]) {
407
+ function remap(parser: $RefParser, inventory: InventoryEntry[]) {
408
+ perf.log(`Starting remap with ${inventory.length} inventory entries`);
409
+ perf.mark("remap-start");
410
+ const root = parser.schema as any;
411
+
263
412
  // Group & sort all the $ref pointers, so they're in the order that we need to dereference/remap them
413
+ perf.mark("sort-inventory-start");
264
414
  inventory.sort((a: InventoryEntry, b: InventoryEntry) => {
265
415
  if (a.file !== b.file) {
266
416
  // Group all the $refs that point to the same file
@@ -285,7 +435,6 @@ function remap(inventory: InventoryEntry[]) {
285
435
  // Most people will expect references to be bundled into the the "definitions" property if possible.
286
436
  const aDefinitionsIndex = a.pathFromRoot.lastIndexOf("/definitions");
287
437
  const bDefinitionsIndex = b.pathFromRoot.lastIndexOf("/definitions");
288
-
289
438
  if (aDefinitionsIndex !== bDefinitionsIndex) {
290
439
  // Give higher priority to the $ref that's closer to the "definitions" property
291
440
  return bDefinitionsIndex - aDefinitionsIndex;
@@ -296,55 +445,204 @@ function remap(inventory: InventoryEntry[]) {
296
445
  }
297
446
  });
298
447
 
299
- let file, hash, pathFromRoot;
448
+ perf.mark("sort-inventory-end");
449
+ perf.measure("sort-inventory-time", "sort-inventory-start", "sort-inventory-end");
450
+
451
+ perf.log(`Sorted ${inventory.length} inventory entries`);
452
+
453
+ // Ensure or return a container by component type. Prefer OpenAPI-aware placement;
454
+ // otherwise use existing root containers; otherwise create components/*.
455
+ const ensureContainer = (type: "schemas" | "parameters" | "requestBodies" | "responses" | "headers") => {
456
+ const isOas3 = !!(root && typeof root === "object" && typeof root.openapi === "string");
457
+ const isOas2 = !!(root && typeof root === "object" && typeof root.swagger === "string");
458
+
459
+ if (isOas3) {
460
+ if (!root.components || typeof root.components !== "object") {
461
+ root.components = {};
462
+ }
463
+ if (!root.components[type] || typeof root.components[type] !== "object") {
464
+ root.components[type] = {};
465
+ }
466
+ return { obj: root.components[type], prefix: `#/components/${type}` } as const;
467
+ }
468
+
469
+ if (isOas2) {
470
+ if (type === "schemas") {
471
+ if (!root.definitions || typeof root.definitions !== "object") {
472
+ root.definitions = {};
473
+ }
474
+ return { obj: root.definitions, prefix: "#/definitions" } as const;
475
+ }
476
+ if (type === "parameters") {
477
+ if (!root.parameters || typeof root.parameters !== "object") {
478
+ root.parameters = {};
479
+ }
480
+ return { obj: root.parameters, prefix: "#/parameters" } as const;
481
+ }
482
+ if (type === "responses") {
483
+ if (!root.responses || typeof root.responses !== "object") {
484
+ root.responses = {};
485
+ }
486
+ return { obj: root.responses, prefix: "#/responses" } as const;
487
+ }
488
+ // requestBodies/headers don't exist as reusable containers in OAS2; fallback to definitions
489
+ if (!root.definitions || typeof root.definitions !== "object") {
490
+ root.definitions = {};
491
+ }
492
+ return { obj: root.definitions, prefix: "#/definitions" } as const;
493
+ }
494
+
495
+ // No explicit version: prefer existing containers
496
+ if (root && typeof root === "object") {
497
+ if (root.components && typeof root.components === "object") {
498
+ if (!root.components[type] || typeof root.components[type] !== "object") {
499
+ root.components[type] = {};
500
+ }
501
+ return { obj: root.components[type], prefix: `#/components/${type}` } as const;
502
+ }
503
+ if (root.definitions && typeof root.definitions === "object") {
504
+ return { obj: root.definitions, prefix: "#/definitions" } as const;
505
+ }
506
+ // Create components/* by default if nothing exists
507
+ if (!root.components || typeof root.components !== "object") {
508
+ root.components = {};
509
+ }
510
+ if (!root.components[type] || typeof root.components[type] !== "object") {
511
+ root.components[type] = {};
512
+ }
513
+ return { obj: root.components[type], prefix: `#/components/${type}` } as const;
514
+ }
515
+
516
+ // Fallback
517
+ root.definitions = root.definitions || {};
518
+ return { obj: root.definitions, prefix: "#/definitions" } as const;
519
+ };
520
+
521
+ /**
522
+ * Choose the appropriate component container for bundling.
523
+ * Prioritizes the original container type from external files over usage location.
524
+ *
525
+ * @param entry - The inventory entry containing reference information
526
+ * @returns The container type to use for bundling
527
+ */
528
+ const chooseComponent = (entry: InventoryEntry) => {
529
+ // If we have the original container type from the external file, use it
530
+ if (entry.originalContainerType) {
531
+ return entry.originalContainerType;
532
+ }
533
+
534
+ // Fallback to usage path for internal references or when original type is not available
535
+ return getContainerTypeFromPath(entry.pathFromRoot);
536
+ };
537
+
538
+ // Track names per (container prefix) and per target
539
+ const targetToNameByPrefix = new Map<string, Map<string, string>>();
540
+ const usedNamesByObj = new Map<any, Set<string>>();
541
+
542
+ const sanitize = (name: string) => name.replace(/[^A-Za-z0-9_-]/g, "_");
543
+ const baseName = (filePath: string) => {
544
+ try {
545
+ const withoutHash = filePath.split("#")[0];
546
+ const parts = withoutHash.split("/");
547
+ const filename = parts[parts.length - 1] || "schema";
548
+ const dot = filename.lastIndexOf(".");
549
+ return sanitize(dot > 0 ? filename.substring(0, dot) : filename);
550
+ } catch {
551
+ return "schema";
552
+ }
553
+ };
554
+ const lastToken = (hash: string) => {
555
+ if (!hash || hash === "#") {
556
+ return "root";
557
+ }
558
+ const tokens = hash.replace(/^#\//, "").split("/");
559
+ return sanitize(tokens[tokens.length - 1] || "root");
560
+ };
561
+ const uniqueName = (containerObj: any, proposed: string) => {
562
+ if (!usedNamesByObj.has(containerObj)) {
563
+ usedNamesByObj.set(containerObj, new Set<string>(Object.keys(containerObj || {})));
564
+ }
565
+ const used = usedNamesByObj.get(containerObj)!;
566
+ let name = proposed;
567
+ let i = 2;
568
+ while (used.has(name)) {
569
+ name = `${proposed}_${i++}`;
570
+ }
571
+ used.add(name);
572
+ return name;
573
+ };
574
+ perf.mark("remap-loop-start");
300
575
  for (const entry of inventory) {
301
- // console.log('Re-mapping $ref pointer "%s" at %s', entry.$ref.$ref, entry.pathFromRoot);
302
-
303
- if (!entry.external && !entry.hash?.startsWith("#/paths/")) {
304
- // This $ref already resolves to the main JSON Schema file
305
- entry.$ref.$ref = entry.hash;
306
- } else if (entry.file === file && entry.hash === hash) {
307
- // This $ref points to the same value as the prevous $ref, so remap it to the same path
308
- entry.$ref.$ref = pathFromRoot;
309
- } else if (entry.file === file && entry.hash.indexOf(hash + "/") === 0) {
310
- // This $ref points to a sub-value of the prevous $ref, so remap it beneath that path
311
- entry.$ref.$ref = Pointer.join(pathFromRoot, Pointer.parse(entry.hash.replace(hash, "#")));
312
- } else {
313
- // We've moved to a new file or new hash
314
- file = entry.file;
315
- hash = entry.hash;
316
- pathFromRoot = entry.pathFromRoot;
576
+ // Safety check: ensure entry and entry.$ref are valid objects
577
+ if (!entry || !entry.$ref || typeof entry.$ref !== "object") {
578
+ perf.warn(`Skipping invalid inventory entry:`, entry);
579
+ continue;
580
+ }
317
581
 
318
- // This is the first $ref to point to this value, so dereference the value.
319
- // Any other $refs that point to the same value will point to this $ref instead
320
- entry.$ref = entry.parent[entry.key] = $Ref.dereference(entry.$ref, entry.value);
582
+ // Keep internal refs internal
583
+ if (!entry.external) {
584
+ if (entry.$ref && typeof entry.$ref === "object") {
585
+ entry.$ref.$ref = entry.hash;
586
+ }
587
+ continue;
588
+ }
321
589
 
322
- if (entry.circular) {
323
- // This $ref points to itself
590
+ // Avoid changing direct self-references; keep them internal
591
+ if (entry.circular) {
592
+ if (entry.$ref && typeof entry.$ref === "object") {
324
593
  entry.$ref.$ref = entry.pathFromRoot;
325
594
  }
595
+ continue;
596
+ }
597
+
598
+ // Choose appropriate container based on original location in external file
599
+ const component = chooseComponent(entry);
600
+ const { obj: container, prefix } = ensureContainer(component);
601
+
602
+ const targetKey = `${entry.file}::${entry.hash}`;
603
+ if (!targetToNameByPrefix.has(prefix)) {
604
+ targetToNameByPrefix.set(prefix, new Map<string, string>());
605
+ }
606
+ const namesForPrefix = targetToNameByPrefix.get(prefix)!;
607
+
608
+ let defName = namesForPrefix.get(targetKey);
609
+ if (!defName) {
610
+ // If the external file is one of the original input sources, prefer its assigned prefix
611
+ let proposedBase = baseName(entry.file);
612
+ try {
613
+ const parserAny: any = parser as any;
614
+ if (parserAny && parserAny.sourcePathToPrefix && typeof parserAny.sourcePathToPrefix.get === "function") {
615
+ const withoutHash = (entry.file || "").split("#")[0];
616
+ const mapped = parserAny.sourcePathToPrefix.get(withoutHash);
617
+ if (mapped && typeof mapped === "string") {
618
+ proposedBase = mapped;
619
+ }
620
+ }
621
+ } catch {
622
+ // Ignore errors
623
+ }
624
+ const proposed = `${proposedBase}_${lastToken(entry.hash)}`;
625
+ defName = uniqueName(container, proposed);
626
+ namesForPrefix.set(targetKey, defName);
627
+ // Store the resolved value under the container
628
+ container[defName] = entry.value;
629
+ }
630
+
631
+ // Point the occurrence to the internal definition, preserving extensions
632
+ const refPath = `${prefix}/${defName}`;
633
+ if (entry.extended && entry.$ref && typeof entry.$ref === "object") {
634
+ entry.$ref.$ref = refPath;
635
+ } else {
636
+ entry.parent[entry.key] = { $ref: refPath };
326
637
  }
327
638
  }
639
+ perf.mark("remap-loop-end");
640
+ perf.measure("remap-loop-time", "remap-loop-start", "remap-loop-end");
641
+
642
+ perf.mark("remap-end");
643
+ perf.measure("remap-total-time", "remap-start", "remap-end");
328
644
 
329
- // we want to ensure that any $refs that point to another $ref are remapped to point to the final value
330
- // let hadChange = true;
331
- // while (hadChange) {
332
- // hadChange = false;
333
- // for (const entry of inventory) {
334
- // if (entry.$ref && typeof entry.$ref === "object" && "$ref" in entry.$ref) {
335
- // const resolved = inventory.find((e: InventoryEntry) => e.pathFromRoot === entry.$ref.$ref);
336
- // if (resolved) {
337
- // const resolvedPointsToAnotherRef =
338
- // resolved.$ref && typeof resolved.$ref === "object" && "$ref" in resolved.$ref;
339
- // if (resolvedPointsToAnotherRef && entry.$ref.$ref !== resolved.$ref.$ref) {
340
- // // console.log('Re-mapping $ref pointer "%s" at %s', entry.$ref.$ref, entry.pathFromRoot);
341
- // entry.$ref.$ref = resolved.$ref.$ref;
342
- // hadChange = true;
343
- // }
344
- // }
345
- // }
346
- // }
347
- // }
645
+ perf.log(`Completed remap of ${inventory.length} entries`);
348
646
  }
349
647
 
350
648
  function removeFromInventory(inventory: InventoryEntry[], entry: any) {
@@ -362,8 +660,18 @@ function removeFromInventory(inventory: InventoryEntry[], entry: any) {
362
660
  */
363
661
  export const bundle = (parser: $RefParser, options: ParserOptions) => {
364
662
  // console.log('Bundling $ref pointers in %s', parser.$refs._root$Ref.path);
663
+ perf.mark("bundle-start");
664
+
365
665
  // Build an inventory of all $ref pointers in the JSON Schema
366
666
  const inventory: InventoryEntry[] = [];
667
+ const inventoryLookup = createInventoryLookup();
668
+
669
+ perf.log("Starting crawl phase");
670
+ perf.mark("crawl-phase-start");
671
+
672
+ const visitedObjects = new WeakSet<object>();
673
+ const resolvedRefs = new Map<string, any>(); // Cache for resolved $ref targets
674
+
367
675
  crawl<JSONSchema>({
368
676
  parent: parser,
369
677
  key: "schema",
@@ -371,10 +679,47 @@ export const bundle = (parser: $RefParser, options: ParserOptions) => {
371
679
  pathFromRoot: "#",
372
680
  indirections: 0,
373
681
  inventory,
682
+ inventoryLookup,
374
683
  $refs: parser.$refs,
375
684
  options,
685
+ visitedObjects,
686
+ resolvedRefs,
376
687
  });
377
688
 
689
+ perf.mark("crawl-phase-end");
690
+ perf.measure("crawl-phase-time", "crawl-phase-start", "crawl-phase-end");
691
+
692
+ const stats = inventoryLookup.getStats();
693
+ perf.log(`Crawl phase complete. Found ${inventory.length} $refs. Lookup stats:`, stats);
694
+
378
695
  // Remap all $ref pointers
379
- remap(inventory);
696
+ perf.log("Starting remap phase");
697
+ perf.mark("remap-phase-start");
698
+ remap(parser, inventory);
699
+ perf.mark("remap-phase-end");
700
+ perf.measure("remap-phase-time", "remap-phase-start", "remap-phase-end");
701
+
702
+ perf.mark("bundle-end");
703
+ perf.measure("bundle-total-time", "bundle-start", "bundle-end");
704
+
705
+ perf.log("Bundle complete. Performance summary:");
706
+
707
+ // Log final stats
708
+ const finalStats = inventoryLookup.getStats();
709
+ perf.log(`Final inventory stats:`, finalStats);
710
+ perf.log(`Resolved refs cache size: ${resolvedRefs.size}`);
711
+
712
+ if (DEBUG_PERFORMANCE) {
713
+ // Log all performance measures
714
+ const measures = performance.getEntriesByType("measure");
715
+ measures.forEach((measure) => {
716
+ if (measure.name.includes("time")) {
717
+ console.log(`${measure.name}: ${measure.duration.toFixed(2)}ms`);
718
+ }
719
+ });
720
+
721
+ // Clear performance marks and measures for next run
722
+ performance.clearMarks();
723
+ performance.clearMeasures();
724
+ }
380
725
  };