Skip to content

Commit 64857f8

Browse files
committed
Merge options
BREAKING CHANGE: Change default merge keys option and add merge keys strategy, bump dependencies
1 parent 6a469b7 commit 64857f8

File tree

10 files changed

+904
-871
lines changed

10 files changed

+904
-871
lines changed
Lines changed: 331 additions & 331 deletions
Large diffs are not rendered by default.

.yarnrc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
nodeLinker: node-modules
22

3-
yarnPath: .yarn/releases/yarn-4.9.4.cjs
3+
yarnPath: .yarn/releases/yarn-4.11.0.cjs

lib/bundle.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ function bundle<S extends object = JSONSchema, O extends ParserOptions<S> = Pars
4040
crawl<S, O>(parser, "schema", parser.$refs._root$Ref.path + "#", "#", 0, inventory, parser.$refs, options);
4141

4242
// Remap all $ref pointers
43-
remap(inventory);
43+
remap<S, O>(inventory, options);
4444
}
4545

4646
/**
@@ -203,7 +203,10 @@ function inventory$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
203203
*
204204
* @param inventory
205205
*/
206-
function remap(inventory: InventoryEntry[]) {
206+
function remap<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOptions<S>>(
207+
inventory: InventoryEntry[],
208+
options: O,
209+
) {
207210
// Group & sort all the $ref pointers, so they're in the order that we need to dereference/remap them
208211
inventory.sort((a: InventoryEntry, b: InventoryEntry) => {
209212
if (a.file !== b.file) {
@@ -254,10 +257,10 @@ function remap(inventory: InventoryEntry[]) {
254257
// This $ref already resolves to the main JSON Schema file
255258
entry.$ref.$ref = entry.hash;
256259
} else if (entry.file === file && entry.hash === hash) {
257-
// This $ref points to the same value as the prevous $ref, so remap it to the same path
260+
// This $ref points to the same value as the previous $ref, so remap it to the same path
258261
entry.$ref.$ref = pathFromRoot;
259262
} else if (entry.file === file && entry.hash.indexOf(hash + "/") === 0) {
260-
// This $ref points to a sub-value of the prevous $ref, so remap it beneath that path
263+
// This $ref points to a sub-value of the previous $ref, so remap it beneath that path
261264
entry.$ref.$ref = Pointer.join(pathFromRoot, Pointer.parse(entry.hash.replace(hash, "#")));
262265
} else {
263266
// We've moved to a new file or new hash
@@ -267,7 +270,7 @@ function remap(inventory: InventoryEntry[]) {
267270

268271
// This is the first $ref to point to this value, so dereference the value.
269272
// Any other $refs that point to the same value will point to this $ref instead
270-
entry.$ref = entry.parent[entry.key] = $Ref.dereference(entry.$ref, entry.value);
273+
entry.$ref = entry.parent[entry.key] = $Ref.dereference(entry.$ref, entry.value, options);
271274

272275
if (entry.circular) {
273276
// This $ref points to itself

lib/dereference.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
278278
}
279279

280280
// Dereference the JSON reference
281-
let dereferencedValue = $Ref.dereference($ref, pointer.value);
281+
let dereferencedValue = $Ref.dereference($ref, pointer.value, options);
282282

283283
// Crawl the dereferenced value (unless it's circular)
284284
if (!circular) {

lib/options.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ export interface DereferenceOptions {
8282
* Default: `relative`
8383
*/
8484
externalReferenceResolution?: "relative" | "root";
85+
86+
/**
87+
* Whether duplicate keys should be merged when dereferencing objects.
88+
*
89+
* Default: `true`
90+
*/
91+
mergeKeys?: boolean;
8592
}
8693

8794
/**
@@ -229,6 +236,7 @@ export const getJsonSchemaRefParserDefaultOptions = () => {
229236
*/
230237
excludedPathMatcher: () => false,
231238
referenceResolution: "relative",
239+
mergeKeys: true,
232240
},
233241

234242
mutateInputSchema: true,

lib/pointer.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import $Ref from "./ref.js";
44
import * as url from "./util/url.js";
55
import { JSONParserError, InvalidPointerError, MissingPointerError, isHandledError } from "./util/errors.js";
66
import type { JSONSchema } from "./types";
7+
import type { JSONSchema4Type, JSONSchema6Type, JSONSchema7Type } from "json-schema";
78

89
export const nullSymbol = Symbol("null");
910

@@ -90,7 +91,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
9091
*/
9192
resolve(obj: S, options?: O, pathFromRoot?: string) {
9293
const tokens = Pointer.parse(this.path, this.originalPath);
93-
const found: any = [];
94+
const found: string[] = [];
9495

9596
// Crawl the object, one token at a time
9697
this.value = unwrapOrThrow(obj);
@@ -163,7 +164,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
163164
* @returns
164165
* Returns the modified object, or an entirely new object if the entire object is overwritten.
165166
*/
166-
set(obj: S, value: any, options?: O) {
167+
set(obj: S, value: JSONSchema4Type | JSONSchema6Type | JSONSchema7Type, options?: O) {
167168
const tokens = Pointer.parse(this.path);
168169
let token;
169170

@@ -190,7 +191,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
190191
}
191192

192193
// Set the value of the final token
193-
resolveIf$Ref(this, options);
194+
resolveIf$Ref<S, O>(this, options);
194195
token = tokens[tokens.length - 1];
195196
setValue(this, token, value);
196197

@@ -271,7 +272,11 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
271272
* @param [pathFromRoot] - the path of place that initiated resolving
272273
* @returns - Returns `true` if the resolution path changed
273274
*/
274-
function resolveIf$Ref(pointer: any, options: any, pathFromRoot?: any) {
275+
function resolveIf$Ref<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOptions<S>>(
276+
pointer: Pointer,
277+
options: O | undefined,
278+
pathFromRoot?: string,
279+
) {
275280
// Is the value a JSON reference? (and allowed?)
276281

277282
if ($Ref.isAllowed$Ref(pointer.value, options)) {
@@ -291,7 +296,7 @@ function resolveIf$Ref(pointer: any, options: any, pathFromRoot?: any) {
291296
if ($Ref.isExtended$Ref(pointer.value)) {
292297
// This JSON reference "extends" the resolved value, rather than simply pointing to it.
293298
// So the resolved path does NOT change. Just the value does.
294-
pointer.value = $Ref.dereference(pointer.value, resolved.value);
299+
pointer.value = $Ref.dereference(pointer.value, resolved.value, options);
295300
return false;
296301
} else {
297302
// Resolve the reference
@@ -318,7 +323,7 @@ export default Pointer;
318323
* @param value - The value to assign
319324
* @returns - Returns the assigned value
320325
*/
321-
function setValue(pointer: any, token: any, value: any) {
326+
function setValue(pointer: Pointer, token: string, value: JSONSchema4Type | JSONSchema6Type | JSONSchema7Type) {
322327
if (pointer.value && typeof pointer.value === "object") {
323328
if (token === "-" && Array.isArray(pointer.value)) {
324329
pointer.value.push(value);
@@ -333,14 +338,14 @@ function setValue(pointer: any, token: any, value: any) {
333338
return value;
334339
}
335340

336-
function unwrapOrThrow(value: any) {
341+
function unwrapOrThrow(value: unknown) {
337342
if (isHandledError(value)) {
338343
throw value;
339344
}
340345

341346
return value;
342347
}
343348

344-
function isRootPath(pathFromRoot: any): boolean {
349+
function isRootPath(pathFromRoot: string | unknown): boolean {
345350
return typeof pathFromRoot == "string" && Pointer.parse(pathFromRoot).length == 0;
346351
}

lib/ref.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { safePointerToPath, stripHash, getHash } from "./util/url.js";
55
import type $Refs from "./refs.js";
66
import type { ParserOptions } from "./options.js";
77
import type { JSONSchema } from "./types";
8+
import type { JSONSchema4Type, JSONSchema6Type, JSONSchema7Type } from "json-schema";
89

910
export type $RefError = JSONParserError | ResolverError | ParserError | MissingPointerError;
1011

@@ -150,7 +151,7 @@ class $Ref<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOpt
150151
* @param path - The full path of the property to set, optionally with a JSON pointer in the hash
151152
* @param value - The value to assign
152153
*/
153-
set(path: string, value: any) {
154+
set(path: string, value: JSONSchema4Type | JSONSchema6Type | JSONSchema7Type) {
154155
const pointer = new Pointer(this, path);
155156
this.value = pointer.set(this.value, value);
156157
if (this.value === nullSymbol) {
@@ -273,25 +274,41 @@ class $Ref<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOpt
273274
*
274275
* @param $ref - The JSON reference object (the one with the "$ref" property)
275276
* @param resolvedValue - The resolved value, which can be any type
277+
* @param options - The options
276278
* @returns - Returns the dereferenced value
277279
*/
278280
static dereference<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOptions<S>>(
279281
$ref: $Ref<S, O>,
280282
resolvedValue: S,
283+
options?: O,
281284
): S {
282285
if (resolvedValue && typeof resolvedValue === "object" && $Ref.isExtended$Ref($ref)) {
283-
const merged = {};
286+
const merged = {} as Partial<S>;
284287
for (const key of Object.keys($ref)) {
285288
if (key !== "$ref") {
286289
// @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
287290
merged[key] = $ref[key];
288291
}
289292
}
290293

291-
for (const key of Object.keys(resolvedValue)) {
294+
const mergeKeys = options?.dereference?.mergeKeys ?? true;
295+
296+
for (const _key of Object.keys(resolvedValue)) {
297+
const key = _key as keyof S;
292298
if (!(key in merged)) {
293-
// @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
294299
merged[key] = resolvedValue[key];
300+
} else {
301+
// TODO: this behavior should be configurable from options on the CLI
302+
// Key is already in merged, so we should merge them if both are objects
303+
if (
304+
mergeKeys &&
305+
typeof merged[key] === "object" &&
306+
merged[key] !== null &&
307+
typeof resolvedValue[key] === "object" &&
308+
resolvedValue[key] !== null
309+
) {
310+
merged[key] = deepMerge<typeof merged[keyof S]>(resolvedValue[key], merged[key]);
311+
}
295312
}
296313
}
297314

@@ -303,4 +320,37 @@ class $Ref<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOpt
303320
}
304321
}
305322

323+
function deepMerge<T>(target: Partial<T>, source: Partial<T>): T {
324+
//return {...target, ...source};
325+
326+
// If either isn't an object, just return source (overwrite)
327+
if (typeof target !== "object" || target === null) {
328+
return source as T;
329+
}
330+
if (typeof source !== "object" || source === null) {
331+
return source;
332+
}
333+
334+
// Ensure we don't mutate target directly
335+
const output = Array.isArray(target) ? [...target] : { ...target };
336+
337+
for (const key of Object.keys(source)) {
338+
// @ts-expect-error
339+
if (Array.isArray(source[key])) {
340+
// If it's an array, replace entirely (you can customize this to concat instead)
341+
// @ts-expect-error
342+
output[key] = [...source[key]];
343+
// @ts-expect-error
344+
} else if (typeof source[key] === "object" && source[key] !== null) {
345+
// @ts-expect-error
346+
output[key] = deepMerge(target[key], source[key]);
347+
} else {
348+
// @ts-expect-error
349+
output[key] = source[key];
350+
}
351+
}
352+
353+
return output as T;
354+
}
355+
306356
export default $Ref;

lib/util/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ export class InvalidPointerError extends JSONParserError {
201201
}
202202
}
203203

204-
export function isHandledError(err: any): err is JSONParserError {
204+
export function isHandledError(err: unknown): err is JSONParserError {
205205
return err instanceof JSONParserError || err instanceof JSONParserErrorGroup;
206206
}
207207

package.json

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,29 +73,29 @@
7373
"js-yaml": "^4.1.0"
7474
},
7575
"devDependencies": {
76-
"@eslint/compat": "^1.3.2",
77-
"@eslint/js": "^9.35.0",
76+
"@eslint/compat": "^1.4.1",
77+
"@eslint/js": "^9.39.1",
7878
"@types/eslint": "^9.6.1",
7979
"@types/js-yaml": "^4.0.9",
8080
"@types/json-schema": "^7.0.15",
8181
"@types/node": "^24",
82-
"@typescript-eslint/eslint-plugin": "^8.43.0",
83-
"@typescript-eslint/parser": "^8.43.0",
84-
"@vitest/coverage-v8": "^3.2.4",
85-
"cross-env": "^10.0.0",
86-
"eslint": "^9.35.0",
82+
"@typescript-eslint/eslint-plugin": "^8.46.4",
83+
"@typescript-eslint/parser": "^8.46.4",
84+
"@vitest/coverage-v8": "^4.0.8",
85+
"cross-env": "^10.1.0",
86+
"eslint": "^9.39.1",
8787
"eslint-config-prettier": "^10.1.8",
8888
"eslint-plugin-import": "^2.32.0",
8989
"eslint-plugin-prettier": "^5.5.4",
9090
"eslint-plugin-promise": "^7.2.1",
91-
"eslint-plugin-unused-imports": "^4.2.0",
92-
"globals": "^16.4.0",
93-
"jsdom": "^26.1.0",
91+
"eslint-plugin-unused-imports": "^4.3.0",
92+
"globals": "^16.5.0",
93+
"jsdom": "^27.1.0",
9494
"prettier": "^3.6.2",
95-
"rimraf": "^6.0.1",
96-
"typescript": "^5.9.2",
97-
"typescript-eslint": "^8.43.0",
98-
"vitest": "^3.2.4"
95+
"rimraf": "^6.1.0",
96+
"typescript": "^5.9.3",
97+
"typescript-eslint": "^8.46.4",
98+
"vitest": "^4.0.8"
9999
},
100100
"release": {
101101
"branches": [
@@ -108,5 +108,5 @@
108108
"@semantic-release/github"
109109
]
110110
},
111-
"packageManager": "yarn@4.9.4"
111+
"packageManager": "yarn@4.11.0"
112112
}

0 commit comments

Comments
 (0)