Skip to content

Commit 6e19cd9

Browse files
authored
fix: resolve multiple open issues (#349, #355, #392) and add regression tests (#415)
- Add `dereference.cloneReferences` option to create independent copies of dereferenced values via structuredClone, preventing shared-reference mutations (#349) - Fix bundle creating invalid cross-$id $ref pointers by qualifying refs with the root document's $id when inside a sub-schema $id scope (#355) - Add `bundle.optimizeInternalRefs` option to control whether internal $ref paths get shortened during bundling (#392) - Add regression tests for #384 (externalReferenceResolution) and #403 (circular $ref: "#" in oneOf)
1 parent 3d93948 commit 6e19cd9

File tree

20 files changed

+362
-16
lines changed

20 files changed

+362
-16
lines changed

lib/bundle.ts

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,20 @@ function bundle<S extends object = JSONSchema, O extends ParserOptions<S> = Pars
3939
const inventory: InventoryEntry[] = [];
4040
crawl<S, O>(parser, "schema", parser.$refs._root$Ref.path + "#", "#", 0, inventory, parser.$refs, options);
4141

42+
// Get the root schema's $id (if any) for qualifying refs inside sub-schemas with their own $id
43+
const rootId =
44+
parser.schema && typeof parser.schema === "object" && "$id" in (parser.schema as any)
45+
? (parser.schema as any).$id
46+
: undefined;
47+
4248
// Remap all $ref pointers
43-
remap<S, O>(inventory, options);
49+
remap<S, O>(inventory, options, rootId);
4450

4551
// Fix any $ref paths that traverse through other $refs (which is invalid per JSON Schema spec)
46-
fixRefsThroughRefs(inventory, parser.schema as any);
52+
const bundleOptions = (options.bundle || {}) as BundleOptions;
53+
if (bundleOptions.optimizeInternalRefs !== false) {
54+
fixRefsThroughRefs(inventory, parser.schema as any);
55+
}
4756
}
4857

4958
/**
@@ -209,6 +218,7 @@ function inventory$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
209218
function remap<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOptions<S>>(
210219
inventory: InventoryEntry[],
211220
options: O,
221+
rootId?: string,
212222
) {
213223
// Group & sort all the $ref pointers, so they're in the order that we need to dereference/remap them
214224
inventory.sort((a: InventoryEntry, b: InventoryEntry) => {
@@ -256,15 +266,31 @@ function remap<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
256266
for (const entry of inventory) {
257267
// console.log('Re-mapping $ref pointer "%s" at %s', entry.$ref.$ref, entry.pathFromRoot);
258268

269+
const bundleOpts = (options.bundle || {}) as BundleOptions;
259270
if (!entry.external) {
260-
// This $ref already resolves to the main JSON Schema file
261-
entry.$ref.$ref = entry.hash;
271+
// This $ref already resolves to the main JSON Schema file.
272+
// When optimizeInternalRefs is false, preserve the original internal ref path
273+
// instead of rewriting it to the fully resolved hash.
274+
if (bundleOpts.optimizeInternalRefs !== false) {
275+
entry.$ref.$ref = entry.hash;
276+
}
262277
} else if (entry.file === file && entry.hash === hash) {
263278
// This $ref points to the same value as the previous $ref, so remap it to the same path
264-
entry.$ref.$ref = pathFromRoot;
279+
if (rootId && isInsideIdScope(inventory, entry)) {
280+
// This entry is inside a sub-schema with its own $id, so a bare root-relative JSON Pointer
281+
// would be resolved relative to that $id, not the document root. Qualify with the root $id.
282+
entry.$ref.$ref = rootId + pathFromRoot;
283+
} else {
284+
entry.$ref.$ref = pathFromRoot;
285+
}
265286
} else if (entry.file === file && entry.hash.indexOf(hash + "/") === 0) {
266287
// This $ref points to a sub-value of the previous $ref, so remap it beneath that path
267-
entry.$ref.$ref = Pointer.join(pathFromRoot, Pointer.parse(entry.hash.replace(hash, "#")));
288+
const subPath = Pointer.join(pathFromRoot, Pointer.parse(entry.hash.replace(hash, "#")));
289+
if (rootId && isInsideIdScope(inventory, entry)) {
290+
entry.$ref.$ref = rootId + subPath;
291+
} else {
292+
entry.$ref.$ref = subPath;
293+
}
268294
} else {
269295
// We've moved to a new file or new hash
270296
file = entry.file;
@@ -409,4 +435,26 @@ function walkPath(schema: any, path: string): any {
409435
return current;
410436
}
411437

438+
/**
439+
* Checks whether the given inventory entry is located inside a sub-schema that has its own $id.
440+
* If so, root-relative JSON Pointer $refs placed at this location would be resolved against
441+
* the $id base URI rather than the document root, making them invalid.
442+
*/
443+
function isInsideIdScope(inventory: InventoryEntry[], entry: InventoryEntry): boolean {
444+
for (const other of inventory) {
445+
// Skip root-level entries
446+
if (other.pathFromRoot === "#" || other.pathFromRoot === "#/") {
447+
continue;
448+
}
449+
// Check if the other entry is an ancestor of the current entry
450+
if (entry.pathFromRoot.startsWith(other.pathFromRoot + "/")) {
451+
// Check if the ancestor's resolved value has a $id
452+
if (other.value && typeof other.value === "object" && "$id" in other.value) {
453+
return true;
454+
}
455+
}
456+
}
457+
return false;
458+
}
459+
412460
export default bundle;

lib/dereference.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,14 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
146146
}
147147
}
148148

149-
obj[key] = dereferenced.value;
149+
// Clone the dereferenced value if cloneReferences is enabled and this is not a
150+
// circular reference. This prevents mutations to one location from affecting others.
151+
let assignedValue = dereferenced.value;
152+
if (derefOptions?.cloneReferences && !circular && assignedValue && typeof assignedValue === "object") {
153+
assignedValue = structuredClone(assignedValue);
154+
}
155+
156+
obj[key] = assignedValue;
150157

151158
// If we have data to preserve and our dereferenced object is still an object then
152159
// we need copy back our preserved data into our dereferenced schema.

lib/options.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ export interface BundleOptions {
3030
* @argument {string} parentPropName - The prop name of the parent object whose value was processed
3131
*/
3232
onBundle?(path: string, value: JSONSchemaObject, parent?: JSONSchemaObject, parentPropName?: string): void;
33+
34+
/**
35+
* Whether to optimize internal `$ref` paths by following intermediate `$ref` chains and
36+
* rewriting them to point directly to the final target. When `false`, intermediate `$ref`
37+
* indirections are preserved as-is.
38+
*
39+
* Default: `true`
40+
*/
41+
optimizeInternalRefs?: boolean;
3342
}
3443

3544
export interface DereferenceOptions {
@@ -98,6 +107,21 @@ export interface DereferenceOptions {
98107
* Default: 500
99108
*/
100109
maxDepth?: number;
110+
111+
/**
112+
* Whether to create independent clones of each `$ref` target value instead of
113+
* reusing the same JS object reference. When `false` (the default), multiple
114+
* `$ref` pointers that resolve to the same value will all share the same object
115+
* in memory, so modifying one will affect all others. When `true`, each `$ref`
116+
* replacement gets its own deep copy, preventing unintended side effects from
117+
* post-dereference mutations.
118+
*
119+
* Note: circular references are never cloned — they always maintain reference
120+
* equality to correctly represent the circular structure.
121+
*
122+
* Default: `false`
123+
*/
124+
cloneReferences?: boolean;
101125
}
102126

103127
/**
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"$id": "base.schema.json",
3+
"$schema": "https://json-schema.org/draft/2019-09/schema",
4+
"type": "object",
5+
"properties": {
6+
"foo": { "type": "string" }
7+
}
8+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, it, expect } from "vitest";
2+
import $RefParser from "../../../lib/index.js";
3+
import path from "../../utils/path.js";
4+
5+
describe("Bundle with $id in sub-schemas (issue #355)", () => {
6+
it("should not create invalid cross-$id $ref pointers when bundling", async () => {
7+
const parser = new $RefParser();
8+
const bundled = await parser.bundle(path.rel("test/specs/bundle-id-refs/root.schema.json"));
9+
10+
// The bundled schema should have both sub-schemas inlined
11+
expect(bundled.oneOf).toHaveLength(2);
12+
13+
const sub1 = bundled.oneOf[0];
14+
const sub2 = bundled.oneOf[1];
15+
16+
// sub1 should have the base schema inlined in its allOf
17+
expect(sub1.$id).toBe("sub1.schema.json");
18+
expect(sub1.allOf[0]).toHaveProperty("$id", "base.schema.json");
19+
expect(sub1.allOf[0]).toHaveProperty("type", "object");
20+
21+
// sub2 also references base.schema.json. Since sub2 has $id: "sub2.schema.json",
22+
// a bare #/oneOf/0/allOf/0 ref would resolve relative to sub2's $id, which is wrong.
23+
// The bundler should qualify the ref with the root $id.
24+
expect(sub2.$id).toBe("sub2.schema.json");
25+
26+
const sub2BaseRef = sub2.allOf[0];
27+
// The ref should be qualified with the root $id to avoid $id scoping issues
28+
expect(sub2BaseRef.$ref).toBe("root.schema.json#/oneOf/0/allOf/0");
29+
});
30+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"$id": "root.schema.json",
3+
"$schema": "https://json-schema.org/draft/2019-09/schema",
4+
"type": "object",
5+
"oneOf": [{ "$ref": "sub1.schema.json" }, { "$ref": "sub2.schema.json" }]
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"$id": "sub1.schema.json",
3+
"$schema": "https://json-schema.org/draft/2019-09/schema",
4+
"type": "object",
5+
"allOf": [{ "$ref": "base.schema.json" }, { "properties": { "name": { "type": "string", "const": "sub1" } } }]
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"$id": "sub2.schema.json",
3+
"$schema": "https://json-schema.org/draft/2019-09/schema",
4+
"type": "object",
5+
"allOf": [{ "$ref": "base.schema.json" }, { "properties": { "name": { "type": "string", "const": "sub2" } } }]
6+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, it, expect } from "vitest";
2+
import $RefParser from "../../../lib/index.js";
3+
import path from "../../utils/path.js";
4+
5+
describe("bundle.optimizeInternalRefs option (issue #392)", () => {
6+
it("should optimize internal ref chains by default", async () => {
7+
const parser = new $RefParser();
8+
const bundled = await parser.bundle(path.rel("test/specs/bundle-no-optimize/root.json"));
9+
10+
// By default, fixRefsThroughRefs optimizes the chain:
11+
// item -> #/definitions/extended -> #/definitions/base
12+
// becomes: item -> #/definitions/base (skipping intermediate)
13+
expect(bundled.properties.item.$ref).toBe("#/definitions/base");
14+
});
15+
16+
it("should preserve internal ref chains when optimizeInternalRefs is false", async () => {
17+
const parser = new $RefParser();
18+
const bundled = await parser.bundle(path.rel("test/specs/bundle-no-optimize/root.json"), {
19+
bundle: { optimizeInternalRefs: false },
20+
});
21+
22+
// With optimization disabled, the original chain is preserved:
23+
// item still points to #/definitions/extended
24+
expect(bundled.properties.item.$ref).toBe("#/definitions/extended");
25+
});
26+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"type": "object",
3+
"definitions": {
4+
"base": {
5+
"type": "object",
6+
"properties": {
7+
"id": { "type": "string" }
8+
}
9+
},
10+
"extended": {
11+
"$ref": "#/definitions/base"
12+
}
13+
},
14+
"properties": {
15+
"item": {
16+
"$ref": "#/definitions/extended"
17+
}
18+
}
19+
}

0 commit comments

Comments
 (0)