@scalar/json-magic 0.12.20 → 0.13.2

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.
@@ -1,3 +1,3 @@
1
1
  export type { LifecyclePlugin, LoaderPlugin, Plugin, ResolveResult } from './bundle.js';
2
- export { bundle, resolveAndCopyReferences } from './bundle.js';
2
+ export { bundle, extensions, isLocalRef, prefixInternalRef, prefixInternalRefRecursive, resolveAndCopyReferences, } from './bundle.js';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bundle/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AACpF,OAAO,EAAE,MAAM,EAAE,wBAAwB,EAAE,MAAM,UAAU,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bundle/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AACpF,OAAO,EACL,MAAM,EACN,UAAU,EACV,UAAU,EACV,iBAAiB,EACjB,0BAA0B,EAC1B,wBAAwB,GACzB,MAAM,UAAU,CAAA"}
@@ -1 +1 @@
1
- export { bundle, resolveAndCopyReferences } from './bundle.js';
1
+ export { bundle, extensions, isLocalRef, prefixInternalRef, prefixInternalRefRecursive, resolveAndCopyReferences, } from './bundle.js';
@@ -7,9 +7,25 @@ export declare class InvalidChangesDetectedError extends Error {
7
7
  * The function traverses the document structure following the paths specified in the differences
8
8
  * and applies the corresponding changes (add, update, or delete) at each location.
9
9
  *
10
- * @param document - The original document to apply changes to
10
+ * Paths that reach the prototype chain (`__proto__`, `constructor` or `prototype`) are rejected
11
+ * before anything is written, so a hostile changeset cannot poison `Object.prototype`.
12
+ *
13
+ * A change with an empty path asks to replace the document itself, which is not supported: the
14
+ * function writes through the parent container of each path and the root has no parent. `diff`
15
+ * emits such a change whenever the two documents differ at the root, which covers a different
16
+ * `typeof`, `null` against an object, and an array on one side against a plain object on the
17
+ * other. Those changesets have to be handled by the caller instead of being applied.
18
+ *
19
+ * ⚠️ `document` is mutated in place and the result shares structure with the document the diff was
20
+ * built from: every `add` and `update` writes the change into the document by reference, and those
21
+ * changes are live references into the target document `diff` compared (see `diff`). A later write
22
+ * into the result can therefore be seen through that document, and the other way around. Callers
23
+ * that need an isolated result have to deep clone the document and the changes first.
24
+ *
25
+ * @param document - The original document to apply changes to, mutated in place
11
26
  * @param diff - Array of differences to apply, each containing a path and change type
12
- * @returns The modified document with all changes applied
27
+ * @returns The modified document with all changes applied, structurally shared with the changes
28
+ * @throws {InvalidChangesDetectedError} When a path is unusable, empty or reaches the prototype chain
13
29
  *
14
30
  * @example
15
31
  * const original = {
@@ -1 +1 @@
1
- {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/diff/apply.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAE7C,qBAAa,2BAA4B,SAAQ,KAAK;gBACxC,OAAO,EAAE,MAAM;CAI5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,KAAK,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACrD,UAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,MAAM,UAAU,CAAC,CAAC,CAAC,EAAE,KACpB,CAyCF,CAAA"}
1
+ {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/diff/apply.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAE7C,qBAAa,2BAA4B,SAAQ,KAAK;gBACxC,OAAO,EAAE,MAAM;CAI5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,eAAO,MAAM,KAAK,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACrD,UAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,MAAM,UAAU,CAAC,CAAC,CAAC,EAAE,KACpB,CAkEF,CAAA"}
@@ -1,3 +1,4 @@
1
+ import { isPollutionKey } from '@scalar/helpers/object/prevent-pollution';
1
2
  export class InvalidChangesDetectedError extends Error {
2
3
  constructor(message) {
3
4
  super(message);
@@ -9,9 +10,25 @@ export class InvalidChangesDetectedError extends Error {
9
10
  * The function traverses the document structure following the paths specified in the differences
10
11
  * and applies the corresponding changes (add, update, or delete) at each location.
11
12
  *
12
- * @param document - The original document to apply changes to
13
+ * Paths that reach the prototype chain (`__proto__`, `constructor` or `prototype`) are rejected
14
+ * before anything is written, so a hostile changeset cannot poison `Object.prototype`.
15
+ *
16
+ * A change with an empty path asks to replace the document itself, which is not supported: the
17
+ * function writes through the parent container of each path and the root has no parent. `diff`
18
+ * emits such a change whenever the two documents differ at the root, which covers a different
19
+ * `typeof`, `null` against an object, and an array on one side against a plain object on the
20
+ * other. Those changesets have to be handled by the caller instead of being applied.
21
+ *
22
+ * ⚠️ `document` is mutated in place and the result shares structure with the document the diff was
23
+ * built from: every `add` and `update` writes the change into the document by reference, and those
24
+ * changes are live references into the target document `diff` compared (see `diff`). A later write
25
+ * into the result can therefore be seen through that document, and the other way around. Callers
26
+ * that need an isolated result have to deep clone the document and the changes first.
27
+ *
28
+ * @param document - The original document to apply changes to, mutated in place
13
29
  * @param diff - Array of differences to apply, each containing a path and change type
14
- * @returns The modified document with all changes applied
30
+ * @returns The modified document with all changes applied, structurally shared with the changes
31
+ * @throws {InvalidChangesDetectedError} When a path is unusable, empty or reaches the prototype chain
15
32
  *
16
33
  * @example
17
34
  * const original = {
@@ -64,6 +81,24 @@ export const apply = (document, diff) => {
64
81
  }
65
82
  applyChange(current[path[depth]], path, d, depth + 1);
66
83
  };
84
+ // Reject the two kinds of unusable entry we can spot without walking the document - a root level
85
+ // change and a prototype reaching path - before any entry touches it. A path that does not exist
86
+ // is only found while traversing, so that one can still leave the document half updated.
87
+ for (const d of diff) {
88
+ // An empty path targets the document itself. We only ever write through the parent container of
89
+ // a path, so there is nothing to write into for the root, and the caller has to swap the
90
+ // document out on its own.
91
+ if (d.path.length === 0) {
92
+ throw new InvalidChangesDetectedError('Process aborted. Root-level replacement is not supported, the change targets the document itself instead of a property inside it');
93
+ }
94
+ // A path segment such as `__proto__` would make the traversal walk onto `Object.prototype` and
95
+ // write there, poisoning every object in the runtime. `diff` never emits these segments, so
96
+ // only a hand-crafted changeset reaches this guard.
97
+ const unsafeSegment = d.path.find(isPollutionKey);
98
+ if (unsafeSegment !== undefined) {
99
+ throw new InvalidChangesDetectedError(`Process aborted. Path ${d.path.join('.')} contains the unsafe segment "${unsafeSegment}", which can modify the prototype chain`);
100
+ }
101
+ }
67
102
  for (const d of diff) {
68
103
  applyChange(document, d.path, d);
69
104
  }
@@ -22,6 +22,16 @@ export type Difference<_T> = {
22
22
  * This function performs a breadth-first comparison between two objects and returns
23
23
  * a list of operations needed to transform the first object into the second.
24
24
  *
25
+ * Keys that reach the prototype chain (`__proto__`, `constructor` and `prototype`) are skipped, so
26
+ * an untrusted document cannot produce a diff that poisons `Object.prototype` once applied.
27
+ *
28
+ * ⚠️ The returned `changes` are live references into the documents, not clones. An `add` or an
29
+ * `update` carries the very subtree `doc2` holds, and a `delete` carries the subtree from `doc1`,
30
+ * so writing into a change writes into the document it came from. This matters downstream:
31
+ * `merge` merges values into these objects, and `apply` writes them into its target document,
32
+ * which leaves the result structurally shared with `doc2`. Callers that need isolation have to
33
+ * deep clone the documents before diffing them, or the changes afterwards.
34
+ *
25
35
  * @param doc1 - The source object to compare from
26
36
  * @param doc2 - The target object to compare to
27
37
  * @returns A list of operations (add/update/delete) with their paths and changes
@@ -1 +1 @@
1
- {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/diff/diff.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,KAAK,UAAU,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAE7C;;;;;GAKG;AACH,MAAM,MAAM,UAAU,CAAC,EAAE,IAAI;IAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,GAAG,CAAC;IAAC,IAAI,EAAE,UAAU,CAAA;CAAE,CAAA;AAE/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,IAAI,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,oBAyC7F,CAAA"}
1
+ {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/diff/diff.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,KAAK,UAAU,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAE7C;;;;;GAKG;AACH,MAAM,MAAM,UAAU,CAAC,EAAE,IAAI;IAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,GAAG,CAAC;IAAC,IAAI,EAAE,UAAU,CAAA;CAAE,CAAA;AAE/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,eAAO,MAAM,IAAI,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,oBAkE7F,CAAA"}
package/dist/diff/diff.js CHANGED
@@ -1,9 +1,20 @@
1
+ import { isPollutionKey } from '@scalar/helpers/object/prevent-pollution';
1
2
  /**
2
3
  * Get the difference between two objects.
3
4
  *
4
5
  * This function performs a breadth-first comparison between two objects and returns
5
6
  * a list of operations needed to transform the first object into the second.
6
7
  *
8
+ * Keys that reach the prototype chain (`__proto__`, `constructor` and `prototype`) are skipped, so
9
+ * an untrusted document cannot produce a diff that poisons `Object.prototype` once applied.
10
+ *
11
+ * ⚠️ The returned `changes` are live references into the documents, not clones. An `add` or an
12
+ * `update` carries the very subtree `doc2` holds, and a `delete` carries the subtree from `doc1`,
13
+ * so writing into a change writes into the document it came from. This matters downstream:
14
+ * `merge` merges values into these objects, and `apply` writes them into its target document,
15
+ * which leaves the result structurally shared with `doc2`. Callers that need isolation have to
16
+ * deep clone the documents before diffing them, or the changes afterwards.
17
+ *
7
18
  * @param doc1 - The source object to compare from
8
19
  * @param doc2 - The target object to compare to
9
20
  * @returns A list of operations (add/update/delete) with their paths and changes
@@ -52,8 +63,31 @@ export const diff = (doc1, doc2) => {
52
63
  // We now can assume that el1 and el2 are of the same type
53
64
  // For nested objects, we need to recursively check the properties
54
65
  if (typeof el1 === 'object' && typeof el2 === 'object' && el1 !== null && el2 !== null) {
55
- const keys = new Set([...Object.keys(el1), ...Object.keys(el2)]);
56
- for (const key of keys) {
66
+ // A container type change (array to plain object or vice versa) is a single update.
67
+ // Recursing would treat array indices as object keys and produce per-key
68
+ // differences that corrupt the container when applied.
69
+ if (Array.isArray(el1) !== Array.isArray(el2)) {
70
+ diff.push({ path: prefix, changes: el2, type: 'update' });
71
+ return;
72
+ }
73
+ // Keys that reach `Object.prototype` are dropped before we recurse. `JSON.parse` turns
74
+ // `__proto__` into a real own property that `Object.keys` reports, so an untrusted document
75
+ // would otherwise make us walk the prototype chain and emit a diff that poisons every object
76
+ // in the runtime once applied. `apply` rejects the same segments, so nothing emitted here can
77
+ // be turned away later. Note that this only covers the keys compared position by position: a
78
+ // brand new subtree is emitted as a single value and carries its own keys along untouched.
79
+ const keys = [...new Set([...Object.keys(el1), ...Object.keys(el2)])].filter((key) => !isPollutionKey(key));
80
+ // Removed array elements are applied with `splice` (see `apply`), which re-indexes every
81
+ // element after the removed one. Emitting the highest index first keeps the remaining indices
82
+ // valid, so an array that loses more than one element still applies correctly. Please keep
83
+ // this ordering in place, applying the same deletes in ascending order corrupts the array.
84
+ // Only an array that shrinks can lose elements, so an array that grows keeps the natural
85
+ // ascending order. Equal length arrays cannot lose elements either, they take the reversed
86
+ // branch to keep the guard simple. Deletes nested inside elements are object keys rather than
87
+ // array indices, so they never reach `splice` and are unaffected by the order.
88
+ // `keys` is a fresh array, so reversing it in place is safe.
89
+ const orderedKeys = Array.isArray(el1) && Array.isArray(el2) && el1.length >= el2.length ? keys.reverse() : keys;
90
+ for (const key of orderedKeys) {
57
91
  bfs(el1[key], el2[key], [...prefix, key]);
58
92
  }
59
93
  return;
@@ -5,8 +5,15 @@ import type { Difference } from '../diff/diff.js';
5
5
  * that arise when both diffs modify the same paths. It uses a trie data structure for
6
6
  * efficient path matching and conflict detection.
7
7
  *
8
+ * ⚠️ This function mutates the entries of `diff2`. When two changes on the same path can be folded
9
+ * together without a collision, the value from `diff1` is merged into the `changes` of the `diff2`
10
+ * entry, in place. Diffs built by `diff` carry live references into the documents they came from
11
+ * (see `diff`), so folding two changes together also writes into the document behind `diff2`.
12
+ * Callers that need the source documents to stay untouched have to deep clone them before diffing,
13
+ * or clone the changes afterwards.
14
+ *
8
15
  * @param diff1 - First list of differences
9
- * @param diff2 - Second list of differences
16
+ * @param diff2 - Second list of differences, whose entries are mutated, see the note above
10
17
  * @returns Object containing:
11
18
  * - diffs: Combined list of non-conflicting differences
12
19
  * - conflicts: Array of conflicting difference pairs that need manual resolution
@@ -1 +1 @@
1
- {"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["../../src/diff/merge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAI7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,eAAO,MAAM,KAAK,GAAI,CAAC,EAAE,OAAO,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,OAAO,UAAU,CAAC,CAAC,CAAC,EAAE;;;CA8FtE,CAAA"}
1
+ {"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["../../src/diff/merge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAI7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,eAAO,MAAM,KAAK,GAAI,CAAC,EAAE,OAAO,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,OAAO,UAAU,CAAC,CAAC,CAAC,EAAE;;;CAkGtE,CAAA"}
@@ -6,8 +6,15 @@ import { isArrayEqual, isKeyCollisions, mergeObjects } from '../diff/utils.js';
6
6
  * that arise when both diffs modify the same paths. It uses a trie data structure for
7
7
  * efficient path matching and conflict detection.
8
8
  *
9
+ * ⚠️ This function mutates the entries of `diff2`. When two changes on the same path can be folded
10
+ * together without a collision, the value from `diff1` is merged into the `changes` of the `diff2`
11
+ * entry, in place. Diffs built by `diff` carry live references into the documents they came from
12
+ * (see `diff`), so folding two changes together also writes into the document behind `diff2`.
13
+ * Callers that need the source documents to stay untouched have to deep clone them before diffing,
14
+ * or clone the changes afterwards.
15
+ *
9
16
  * @param diff1 - First list of differences
10
- * @param diff2 - Second list of differences
17
+ * @param diff2 - Second list of differences, whose entries are mutated, see the note above
11
18
  * @returns Object containing:
12
19
  * - diffs: Combined list of non-conflicting differences
13
20
  * - conflicts: Array of conflicting difference pairs that need manual resolution
@@ -65,12 +72,16 @@ export const merge = (diff1, diff2) => {
65
72
  trie.findMatch(diff.path, (value) => {
66
73
  if (diff.type === 'delete') {
67
74
  if (value.changes.type === 'delete') {
68
- // Keep the highest depth delete operation and skip the other
75
+ // Keep the shallowest delete operation and skip the other, since deleting an
76
+ // ancestor already removes everything the deeper delete would have removed.
77
+ // On equal paths the first list keeps the entry and the second one is skipped.
78
+ // Note the two sets are indexed differently: `value.index` points into `diff1`,
79
+ // while `index` points into `diff2`.
69
80
  if (value.changes.path.length > diff.path.length) {
70
81
  skipDiff1.add(value.index);
71
82
  }
72
83
  else {
73
- skipDiff2.add(value.index);
84
+ skipDiff2.add(index);
74
85
  }
75
86
  }
76
87
  else {
@@ -11,8 +11,14 @@
11
11
  */
12
12
  export declare class TrieNode<Value> {
13
13
  value: Value | null;
14
+ /**
15
+ * Children are keyed by path segments taken from untrusted documents, so the map has a null
16
+ * prototype. A plain object would resolve `children['__proto__']` to `Object.prototype`, and
17
+ * `addPath` would then write onto the prototype of every object in the runtime. The map is built
18
+ * here rather than taken as an argument, so a caller cannot hand back a polluting one.
19
+ */
14
20
  children: Record<string, TrieNode<Value>>;
15
- constructor(value: Value | null, children: Record<string, TrieNode<Value>>);
21
+ constructor(value: Value | null);
16
22
  }
17
23
  /**
18
24
  * A trie (prefix tree) data structure implementation.
@@ -1 +1 @@
1
- {"version":3,"file":"trie.d.ts","sourceRoot":"","sources":["../../src/diff/trie.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;GAKG;AACH,qBAAa,QAAQ,CAAC,KAAK;IAEhB,KAAK,EAAE,KAAK,GAAG,IAAI;IACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;gBADzC,KAAK,EAAE,KAAK,GAAG,IAAI,EACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;CAEnD;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,IAAI,CAAC,KAAK;IACrB,OAAO,CAAC,IAAI,CAAiB;;IAK7B;;;;;;;;;;OAUG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,KAAK;IAcpC;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI;CAgC3D"}
1
+ {"version":3,"file":"trie.d.ts","sourceRoot":"","sources":["../../src/diff/trie.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;GAKG;AACH,qBAAa,QAAQ,CAAC,KAAK;IASN,KAAK,EAAE,KAAK,GAAG,IAAI;IARtC;;;;;OAKG;IACI,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAsB;gBAEnD,KAAK,EAAE,KAAK,GAAG,IAAI;CACvC;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,IAAI,CAAC,KAAK;IACrB,OAAO,CAAC,IAAI,CAAiB;;IAK7B;;;;;;;;;;OAUG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,KAAK;IAcpC;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI;CAgC3D"}
package/dist/diff/trie.js CHANGED
@@ -11,10 +11,15 @@
11
11
  */
12
12
  export class TrieNode {
13
13
  value;
14
- children;
15
- constructor(value, children) {
14
+ /**
15
+ * Children are keyed by path segments taken from untrusted documents, so the map has a null
16
+ * prototype. A plain object would resolve `children['__proto__']` to `Object.prototype`, and
17
+ * `addPath` would then write onto the prototype of every object in the runtime. The map is built
18
+ * here rather than taken as an argument, so a caller cannot hand back a polluting one.
19
+ */
20
+ children = Object.create(null);
21
+ constructor(value) {
16
22
  this.value = value;
17
- this.children = children;
18
23
  }
19
24
  }
20
25
  /**
@@ -32,7 +37,7 @@ export class TrieNode {
32
37
  export class Trie {
33
38
  root;
34
39
  constructor() {
35
- this.root = new TrieNode(null, {});
40
+ this.root = new TrieNode(null);
36
41
  }
37
42
  /**
38
43
  * Adds a value to the trie at the specified path.
@@ -52,7 +57,7 @@ export class Trie {
52
57
  current = current.children[dir];
53
58
  }
54
59
  else {
55
- current.children[dir] = new TrieNode(null, {});
60
+ current.children[dir] = new TrieNode(null);
56
61
  current = current.children[dir];
57
62
  }
58
63
  }
@@ -18,6 +18,9 @@
18
18
  *
19
19
  * // Nested objects with collision
20
20
  * isKeyCollisions({ a: { b: 1 } }, { a: { b: 2 } }) // true
21
+ *
22
+ * // An array against a plain object
23
+ * isKeyCollisions([1, 2], { 0: 1, 1: 2 }) // true
21
24
  */
22
25
  export declare const isKeyCollisions: (a: unknown, b: unknown) => boolean;
23
26
  /**
@@ -26,8 +29,14 @@ export declare const isKeyCollisions: (a: unknown, b: unknown) => boolean;
26
29
  * ⚠️ Note: This operation assumes there are no key collisions between the objects.
27
30
  * Use isKeyCollisions() to check for collisions before merging.
28
31
  *
29
- * @param a - Target object to merge into
30
- * @param b - Source object to merge from
32
+ * ⚠️ Note: `a` is mutated in place and the subtrees `b` contributes are attached by reference, not
33
+ * cloned. Those subtrees stay shared with `b`, so a later write into one of them is seen through
34
+ * `b` as well. A key both objects already hold keeps the subtree of `a` and merges into it, so
35
+ * only what `b` brings along is shared. `merge` relies on this to fold two changes into one, which
36
+ * is how a merge ends up writing into the documents its diffs were built from.
37
+ *
38
+ * @param a - Target object to merge into, mutated in place
39
+ * @param b - Source object to merge from, whose subtrees are shared with the result
31
40
  * @returns The merged object (mutates and returns a)
32
41
  *
33
42
  * @example
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/diff/utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,eAAe,GAAI,GAAG,OAAO,EAAE,GAAG,OAAO,YAoBrD,CAAA;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,YAAY,GAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAe3G,CAAA;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,YAAY,GAAI,CAAC,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,EAAE,YAY7C,CAAA"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/diff/utils.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,eAAe,GAAI,GAAG,OAAO,EAAE,GAAG,OAAO,KAAG,OAoCxD,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,YAAY,GAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAsB3G,CAAA;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,YAAY,GAAI,CAAC,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,EAAE,YAY7C,CAAA"}
@@ -1,3 +1,4 @@
1
+ import { isPollutionKey } from '@scalar/helpers/object/prevent-pollution';
1
2
  /**
2
3
  * Deep check for objects for collisions
3
4
  * Check primitives if their values are different
@@ -18,14 +19,31 @@
18
19
  *
19
20
  * // Nested objects with collision
20
21
  * isKeyCollisions({ a: { b: 1 } }, { a: { b: 2 } }) // true
22
+ *
23
+ * // An array against a plain object
24
+ * isKeyCollisions([1, 2], { 0: 1, 1: 2 }) // true
21
25
  */
22
26
  export const isKeyCollisions = (a, b) => {
23
27
  if (typeof a !== typeof b) {
24
28
  return true;
25
29
  }
26
30
  if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
31
+ // An array on one side and a plain object on the other is always a collision. Comparing them
32
+ // key by key matches array indices against object keys, so two containers that hold the same
33
+ // values look mergeable, and `mergeObjects` then absorbs one into the other and drops its type.
34
+ // The same guard lives in `diff`, which reports a container type change as a single update.
35
+ if (Array.isArray(a) !== Array.isArray(b)) {
36
+ return true;
37
+ }
27
38
  const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
28
39
  for (const key of keys) {
40
+ // Skip the keys that reach `Object.prototype`, so this stays in step with `mergeObjects`,
41
+ // which drops them. Without the skip, an own `__proto__` on one side is compared against the
42
+ // inherited prototype of the other and reports a collision that is not really there, turning
43
+ // an otherwise auto-mergeable change into a manual conflict.
44
+ if (isPollutionKey(key)) {
45
+ continue;
46
+ }
29
47
  if (a[key] !== undefined && b[key] !== undefined) {
30
48
  if (isKeyCollisions(a[key], b[key])) {
31
49
  return true;
@@ -43,8 +61,14 @@ export const isKeyCollisions = (a, b) => {
43
61
  * ⚠️ Note: This operation assumes there are no key collisions between the objects.
44
62
  * Use isKeyCollisions() to check for collisions before merging.
45
63
  *
46
- * @param a - Target object to merge into
47
- * @param b - Source object to merge from
64
+ * ⚠️ Note: `a` is mutated in place and the subtrees `b` contributes are attached by reference, not
65
+ * cloned. Those subtrees stay shared with `b`, so a later write into one of them is seen through
66
+ * `b` as well. A key both objects already hold keeps the subtree of `a` and merges into it, so
67
+ * only what `b` brings along is shared. `merge` relies on this to fold two changes into one, which
68
+ * is how a merge ends up writing into the documents its diffs were built from.
69
+ *
70
+ * @param a - Target object to merge into, mutated in place
71
+ * @param b - Source object to merge from, whose subtrees are shared with the result
48
72
  * @returns The merged object (mutates and returns a)
49
73
  *
50
74
  * @example
@@ -60,6 +84,12 @@ export const isKeyCollisions = (a, b) => {
60
84
  */
61
85
  export const mergeObjects = (a, b) => {
62
86
  for (const key in b) {
87
+ // Merging into a prototype-reaching key writes straight onto the prototype of every object in
88
+ // the runtime, so these keys are dropped rather than merged. `diff` skips them as well, which
89
+ // keeps both sides of a merge consistent.
90
+ if (isPollutionKey(key)) {
91
+ continue;
92
+ }
63
93
  if (!(key in a)) {
64
94
  a[key] = b[key];
65
95
  }
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "url": "git+https://github.com/scalar/scalar.git",
11
11
  "directory": "packages/json-magic"
12
12
  },
13
- "version": "0.12.20",
13
+ "version": "0.13.2",
14
14
  "engines": {
15
15
  "node": ">=22"
16
16
  },
@@ -56,6 +56,11 @@
56
56
  "types": "./dist/helpers/get-segments-from-path.d.ts",
57
57
  "default": "./dist/helpers/get-segments-from-path.js"
58
58
  },
59
+ "./helpers/get-value-by-path": {
60
+ "import": "./dist/helpers/get-value-by-path.js",
61
+ "types": "./dist/helpers/get-value-by-path.d.ts",
62
+ "default": "./dist/helpers/get-value-by-path.js"
63
+ },
59
64
  "./helpers/is-file-path": {
60
65
  "import": "./dist/helpers/is-file-path.js",
61
66
  "types": "./dist/helpers/is-file-path.d.ts",
@@ -66,11 +71,26 @@
66
71
  "types": "./dist/helpers/is-http-url.d.ts",
67
72
  "default": "./dist/helpers/is-http-url.js"
68
73
  },
74
+ "./helpers/is-json-object": {
75
+ "import": "./dist/helpers/is-json-object.js",
76
+ "types": "./dist/helpers/is-json-object.d.ts",
77
+ "default": "./dist/helpers/is-json-object.js"
78
+ },
79
+ "./helpers/is-yaml": {
80
+ "import": "./dist/helpers/is-yaml.js",
81
+ "types": "./dist/helpers/is-yaml.d.ts",
82
+ "default": "./dist/helpers/is-yaml.js"
83
+ },
69
84
  "./helpers/normalize": {
70
85
  "import": "./dist/helpers/normalize.js",
71
86
  "types": "./dist/helpers/normalize.d.ts",
72
87
  "default": "./dist/helpers/normalize.js"
73
88
  },
89
+ "./helpers/set-value-at-path": {
90
+ "import": "./dist/helpers/set-value-at-path.js",
91
+ "types": "./dist/helpers/set-value-at-path.d.ts",
92
+ "default": "./dist/helpers/set-value-at-path.js"
93
+ },
74
94
  "./helpers/unescape-json-pointer": {
75
95
  "import": "./dist/helpers/unescape-json-pointer.js",
76
96
  "types": "./dist/helpers/unescape-json-pointer.d.ts",
@@ -89,10 +109,10 @@
89
109
  "dependencies": {
90
110
  "pathe": "^2.0.3",
91
111
  "yaml": "^2.9.0",
92
- "@scalar/helpers": "0.10.0"
112
+ "@scalar/helpers": "0.11.1"
93
113
  },
94
114
  "devDependencies": {
95
- "fastify": "^5.8.1",
115
+ "fastify": "^5.11.2",
96
116
  "vite": "8.1.5"
97
117
  },
98
118
  "scripts": {