zero-query 0.9.9 → 1.0.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 (97) hide show
  1. package/README.md +33 -32
  2. package/cli/args.js +1 -1
  3. package/cli/commands/build.js +2 -2
  4. package/cli/commands/bundle.js +15 -15
  5. package/cli/commands/create.js +2 -2
  6. package/cli/commands/dev/devtools/index.js +1 -1
  7. package/cli/commands/dev/devtools/js/core.js +14 -14
  8. package/cli/commands/dev/devtools/js/elements.js +4 -4
  9. package/cli/commands/dev/devtools/js/stats.js +1 -1
  10. package/cli/commands/dev/devtools/styles.css +2 -2
  11. package/cli/commands/dev/index.js +2 -2
  12. package/cli/commands/dev/logger.js +1 -1
  13. package/cli/commands/dev/overlay.js +21 -14
  14. package/cli/commands/dev/server.js +5 -5
  15. package/cli/commands/dev/validator.js +7 -7
  16. package/cli/commands/dev/watcher.js +6 -6
  17. package/cli/help.js +3 -3
  18. package/cli/index.js +2 -2
  19. package/cli/scaffold/default/app/app.js +17 -18
  20. package/cli/scaffold/default/app/components/about.js +9 -9
  21. package/cli/scaffold/default/app/components/api-demo.js +6 -6
  22. package/cli/scaffold/default/app/components/contact-card.js +4 -4
  23. package/cli/scaffold/default/app/components/contacts/contacts.css +2 -2
  24. package/cli/scaffold/default/app/components/contacts/contacts.html +3 -3
  25. package/cli/scaffold/default/app/components/contacts/contacts.js +11 -11
  26. package/cli/scaffold/default/app/components/counter.js +8 -8
  27. package/cli/scaffold/default/app/components/home.js +13 -13
  28. package/cli/scaffold/default/app/components/not-found.js +1 -1
  29. package/cli/scaffold/default/app/components/playground/playground.css +1 -1
  30. package/cli/scaffold/default/app/components/playground/playground.html +11 -11
  31. package/cli/scaffold/default/app/components/playground/playground.js +11 -11
  32. package/cli/scaffold/default/app/components/todos.js +8 -8
  33. package/cli/scaffold/default/app/components/toolkit/toolkit.css +1 -1
  34. package/cli/scaffold/default/app/components/toolkit/toolkit.html +4 -4
  35. package/cli/scaffold/default/app/components/toolkit/toolkit.js +7 -7
  36. package/cli/scaffold/default/app/routes.js +1 -1
  37. package/cli/scaffold/default/app/store.js +1 -1
  38. package/cli/scaffold/default/global.css +2 -2
  39. package/cli/scaffold/default/index.html +2 -2
  40. package/cli/scaffold/minimal/app/app.js +6 -7
  41. package/cli/scaffold/minimal/app/components/about.js +5 -5
  42. package/cli/scaffold/minimal/app/components/counter.js +6 -6
  43. package/cli/scaffold/minimal/app/components/home.js +8 -8
  44. package/cli/scaffold/minimal/app/components/not-found.js +1 -1
  45. package/cli/scaffold/minimal/app/routes.js +1 -1
  46. package/cli/scaffold/minimal/app/store.js +1 -1
  47. package/cli/scaffold/minimal/global.css +2 -2
  48. package/cli/scaffold/minimal/index.html +1 -1
  49. package/cli/scaffold/ssr/app/app.js +1 -2
  50. package/cli/scaffold/ssr/app/components/about.js +5 -5
  51. package/cli/scaffold/ssr/app/components/home.js +2 -2
  52. package/cli/scaffold/ssr/app/components/not-found.js +1 -1
  53. package/cli/scaffold/ssr/app/routes.js +1 -1
  54. package/cli/scaffold/ssr/global.css +2 -2
  55. package/cli/scaffold/ssr/index.html +2 -2
  56. package/cli/scaffold/ssr/server/index.js +4 -4
  57. package/cli/utils.js +6 -6
  58. package/dist/zquery.dist.zip +0 -0
  59. package/dist/zquery.js +508 -227
  60. package/dist/zquery.min.js +2 -2
  61. package/index.d.ts +16 -13
  62. package/index.js +7 -5
  63. package/package.json +2 -2
  64. package/src/component.js +64 -63
  65. package/src/core.js +15 -15
  66. package/src/diff.js +38 -38
  67. package/src/errors.js +17 -17
  68. package/src/expression.js +15 -17
  69. package/src/http.js +4 -4
  70. package/src/reactive.js +75 -9
  71. package/src/router.js +104 -24
  72. package/src/ssr.js +28 -28
  73. package/src/store.js +103 -21
  74. package/src/utils.js +64 -12
  75. package/tests/audit.test.js +143 -15
  76. package/tests/cli.test.js +20 -20
  77. package/tests/component.test.js +121 -121
  78. package/tests/core.test.js +56 -56
  79. package/tests/diff.test.js +42 -42
  80. package/tests/errors.test.js +5 -5
  81. package/tests/expression.test.js +58 -53
  82. package/tests/http.test.js +20 -20
  83. package/tests/reactive.test.js +185 -24
  84. package/tests/router.test.js +501 -74
  85. package/tests/ssr.test.js +15 -13
  86. package/tests/store.test.js +264 -23
  87. package/tests/utils.test.js +163 -26
  88. package/types/collection.d.ts +2 -2
  89. package/types/component.d.ts +5 -5
  90. package/types/errors.d.ts +3 -3
  91. package/types/http.d.ts +3 -3
  92. package/types/misc.d.ts +9 -9
  93. package/types/reactive.d.ts +25 -3
  94. package/types/router.d.ts +10 -6
  95. package/types/ssr.d.ts +2 -2
  96. package/types/store.d.ts +40 -5
  97. package/types/utils.d.ts +1 -1
package/src/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * zQuery Store Global reactive state management
2
+ * zQuery Store - Global reactive state management
3
3
  *
4
4
  * A lightweight Redux/Vuex-inspired store with:
5
5
  * - Reactive state via Proxy
@@ -38,22 +38,22 @@ class Store {
38
38
  this._history = []; // action log
39
39
  this._maxHistory = config.maxHistory || 1000;
40
40
  this._debug = config.debug || false;
41
+ this._batching = false;
42
+ this._batchQueue = []; // pending notifications during batch
43
+ this._undoStack = [];
44
+ this._redoStack = [];
45
+ this._maxUndo = config.maxUndo || 50;
41
46
 
42
- // Create reactive state
47
+ // Store initial state for reset
43
48
  const initial = typeof config.state === 'function' ? config.state() : { ...(config.state || {}) };
49
+ this._initialState = JSON.parse(JSON.stringify(initial));
44
50
 
45
51
  this.state = reactive(initial, (key, value, old) => {
46
- // Notify key-specific subscribers
47
- const subs = this._subscribers.get(key);
48
- if (subs) subs.forEach(fn => {
49
- try { fn(value, old, key); }
50
- catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, `Subscriber for "${key}" threw`, { key }, err); }
51
- });
52
- // Notify wildcard subscribers
53
- this._wildcards.forEach(fn => {
54
- try { fn(key, value, old); }
55
- catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, 'Wildcard subscriber threw', { key }, err); }
56
- });
52
+ if (this._batching) {
53
+ this._batchQueue.push({ key, value, old });
54
+ return;
55
+ }
56
+ this._notifySubscribers(key, value, old);
57
57
  });
58
58
 
59
59
  // Build getters as computed properties
@@ -66,10 +66,90 @@ class Store {
66
66
  }
67
67
  }
68
68
 
69
+ /** @private Notify key-specific and wildcard subscribers */
70
+ _notifySubscribers(key, value, old) {
71
+ const subs = this._subscribers.get(key);
72
+ if (subs) subs.forEach(fn => {
73
+ try { fn(key, value, old); }
74
+ catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, `Subscriber for "${key}" threw`, { key }, err); }
75
+ });
76
+ this._wildcards.forEach(fn => {
77
+ try { fn(key, value, old); }
78
+ catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, 'Wildcard subscriber threw', { key }, err); }
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Batch multiple state changes - subscribers fire once at the end
84
+ * with only the latest value per key.
85
+ */
86
+ batch(fn) {
87
+ this._batching = true;
88
+ this._batchQueue = [];
89
+ try {
90
+ fn(this.state);
91
+ } finally {
92
+ this._batching = false;
93
+ // Deduplicate: keep only the last change per key
94
+ const last = new Map();
95
+ for (const entry of this._batchQueue) {
96
+ last.set(entry.key, entry);
97
+ }
98
+ this._batchQueue = [];
99
+ for (const { key, value, old } of last.values()) {
100
+ this._notifySubscribers(key, value, old);
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Save a snapshot for undo. Call before making changes you want to be undoable.
107
+ */
108
+ checkpoint() {
109
+ const snap = JSON.parse(JSON.stringify(this.state.__raw || this.state));
110
+ this._undoStack.push(snap);
111
+ if (this._undoStack.length > this._maxUndo) {
112
+ this._undoStack.splice(0, this._undoStack.length - this._maxUndo);
113
+ }
114
+ this._redoStack = [];
115
+ }
116
+
117
+ /**
118
+ * Undo to the last checkpoint
119
+ * @returns {boolean} true if undo was performed
120
+ */
121
+ undo() {
122
+ if (this._undoStack.length === 0) return false;
123
+ const current = JSON.parse(JSON.stringify(this.state.__raw || this.state));
124
+ this._redoStack.push(current);
125
+ const prev = this._undoStack.pop();
126
+ this.replaceState(prev);
127
+ return true;
128
+ }
129
+
130
+ /**
131
+ * Redo the last undone state change
132
+ * @returns {boolean} true if redo was performed
133
+ */
134
+ redo() {
135
+ if (this._redoStack.length === 0) return false;
136
+ const current = JSON.parse(JSON.stringify(this.state.__raw || this.state));
137
+ this._undoStack.push(current);
138
+ const next = this._redoStack.pop();
139
+ this.replaceState(next);
140
+ return true;
141
+ }
142
+
143
+ /** Check if undo is available */
144
+ get canUndo() { return this._undoStack.length > 0; }
145
+
146
+ /** Check if redo is available */
147
+ get canRedo() { return this._redoStack.length > 0; }
148
+
69
149
  /**
70
150
  * Dispatch a named action
71
- * @param {string} name action name
72
- * @param {...any} args payload
151
+ * @param {string} name - action name
152
+ * @param {...any} args - payload
73
153
  */
74
154
  dispatch(name, ...args) {
75
155
  const action = this._actions[name];
@@ -108,13 +188,13 @@ class Store {
108
188
 
109
189
  /**
110
190
  * Subscribe to changes on a specific state key
111
- * @param {string|Function} keyOrFn state key, or function for all changes
112
- * @param {Function} [fn] callback (value, oldValue, key)
113
- * @returns {Function} unsubscribe
191
+ * @param {string|Function} keyOrFn - state key, or function for all changes
192
+ * @param {Function} [fn] - callback (key, value, oldValue)
193
+ * @returns {Function} - unsubscribe
114
194
  */
115
195
  subscribe(keyOrFn, fn) {
116
196
  if (typeof keyOrFn === 'function') {
117
- // Wildcard listen to all changes
197
+ // Wildcard - listen to all changes
118
198
  this._wildcards.add(keyOrFn);
119
199
  return () => this._wildcards.delete(keyOrFn);
120
200
  }
@@ -160,11 +240,13 @@ class Store {
160
240
  }
161
241
 
162
242
  /**
163
- * Reset state to initial values
243
+ * Reset state to initial values. If no argument, resets to the original state.
164
244
  */
165
245
  reset(initialState) {
166
- this.replaceState(initialState);
246
+ this.replaceState(initialState || JSON.parse(JSON.stringify(this._initialState)));
167
247
  this._history = [];
248
+ this._undoStack = [];
249
+ this._redoStack = [];
168
250
  }
169
251
  }
170
252
 
package/src/utils.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * zQuery Utils Common utility functions
2
+ * zQuery Utils - Common utility functions
3
3
  *
4
4
  * Quality-of-life helpers that every frontend project needs.
5
5
  * Attached to $ namespace for convenience.
@@ -10,7 +10,7 @@
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
12
  /**
13
- * Debounce delays execution until after `ms` of inactivity
13
+ * Debounce - delays execution until after `ms` of inactivity
14
14
  */
15
15
  export function debounce(fn, ms = 250) {
16
16
  let timer;
@@ -23,7 +23,7 @@ export function debounce(fn, ms = 250) {
23
23
  }
24
24
 
25
25
  /**
26
- * Throttle limits execution to once per `ms`
26
+ * Throttle - limits execution to once per `ms`
27
27
  */
28
28
  export function throttle(fn, ms = 250) {
29
29
  let last = 0;
@@ -42,14 +42,14 @@ export function throttle(fn, ms = 250) {
42
42
  }
43
43
 
44
44
  /**
45
- * Pipe compose functions left-to-right
45
+ * Pipe - compose functions left-to-right
46
46
  */
47
47
  export function pipe(...fns) {
48
48
  return (input) => fns.reduce((val, fn) => fn(val), input);
49
49
  }
50
50
 
51
51
  /**
52
- * Once function that only runs once
52
+ * Once - function that only runs once
53
53
  */
54
54
  export function once(fn) {
55
55
  let called = false, result;
@@ -60,7 +60,7 @@ export function once(fn) {
60
60
  }
61
61
 
62
62
  /**
63
- * Sleep promise-based delay
63
+ * Sleep - promise-based delay
64
64
  */
65
65
  export function sleep(ms) {
66
66
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -111,8 +111,12 @@ export function trust(htmlStr) {
111
111
  * Generate UUID v4
112
112
  */
113
113
  export function uuid() {
114
- return crypto?.randomUUID?.() || 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
115
- const r = Math.random() * 16 | 0;
114
+ if (crypto?.randomUUID) return crypto.randomUUID();
115
+ // Fallback using crypto.getRandomValues (wider support than randomUUID)
116
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
117
+ const buf = new Uint8Array(1);
118
+ crypto.getRandomValues(buf);
119
+ const r = buf[0] & 15;
116
120
  return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
117
121
  });
118
122
  }
@@ -140,13 +144,50 @@ export function kebabCase(str) {
140
144
  // ---------------------------------------------------------------------------
141
145
 
142
146
  /**
143
- * Deep clone
147
+ * Deep clone via structuredClone (handles circular refs, Dates, etc.).
148
+ * Falls back to a manual deep clone that preserves Date, RegExp, Map, Set,
149
+ * ArrayBuffer, TypedArrays, undefined values, and circular references.
144
150
  */
145
151
  export function deepClone(obj) {
146
152
  if (typeof structuredClone === 'function') return structuredClone(obj);
147
- return JSON.parse(JSON.stringify(obj));
153
+
154
+ const seen = new Map();
155
+ function clone(val) {
156
+ if (val === null || typeof val !== 'object') return val;
157
+ if (seen.has(val)) return seen.get(val);
158
+ if (val instanceof Date) return new Date(val.getTime());
159
+ if (val instanceof RegExp) return new RegExp(val.source, val.flags);
160
+ if (val instanceof Map) {
161
+ const m = new Map();
162
+ seen.set(val, m);
163
+ val.forEach((v, k) => m.set(clone(k), clone(v)));
164
+ return m;
165
+ }
166
+ if (val instanceof Set) {
167
+ const s = new Set();
168
+ seen.set(val, s);
169
+ val.forEach(v => s.add(clone(v)));
170
+ return s;
171
+ }
172
+ if (ArrayBuffer.isView(val)) return new val.constructor(val.buffer.slice(0));
173
+ if (val instanceof ArrayBuffer) return val.slice(0);
174
+ if (Array.isArray(val)) {
175
+ const arr = [];
176
+ seen.set(val, arr);
177
+ for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
178
+ return arr;
179
+ }
180
+ const result = Object.create(Object.getPrototypeOf(val));
181
+ seen.set(val, result);
182
+ for (const key of Object.keys(val)) result[key] = clone(val[key]);
183
+ return result;
184
+ }
185
+ return clone(obj);
148
186
  }
149
187
 
188
+ // Keys that must never be written through data-merge or path-set operations
189
+ const _UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
190
+
150
191
  /**
151
192
  * Deep merge objects
152
193
  */
@@ -156,6 +197,7 @@ export function deepMerge(target, ...sources) {
156
197
  if (seen.has(src)) return tgt;
157
198
  seen.add(src);
158
199
  for (const key of Object.keys(src)) {
200
+ if (_UNSAFE_KEYS.has(key)) continue;
159
201
  if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
160
202
  if (!tgt[key] || typeof tgt[key] !== 'object') tgt[key] = {};
161
203
  merge(tgt[key], src[key]);
@@ -362,10 +404,13 @@ export function setPath(obj, path, value) {
362
404
  let cur = obj;
363
405
  for (let i = 0; i < keys.length - 1; i++) {
364
406
  const k = keys[i];
407
+ if (_UNSAFE_KEYS.has(k)) return obj;
365
408
  if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {};
366
409
  cur = cur[k];
367
410
  }
368
- cur[keys[keys.length - 1]] = value;
411
+ const lastKey = keys[keys.length - 1];
412
+ if (_UNSAFE_KEYS.has(lastKey)) return obj;
413
+ cur[lastKey] = value;
369
414
  return obj;
370
415
  }
371
416
 
@@ -416,9 +461,16 @@ export function memoize(fn, keyFnOrOpts) {
416
461
 
417
462
  const memoized = (...args) => {
418
463
  const key = keyFn ? keyFn(...args) : args[0];
419
- if (cache.has(key)) return cache.get(key);
464
+ if (cache.has(key)) {
465
+ // LRU: promote to newest by re-inserting
466
+ const value = cache.get(key);
467
+ cache.delete(key);
468
+ cache.set(key, value);
469
+ return value;
470
+ }
420
471
  const result = fn(...args);
421
472
  cache.set(key, result);
473
+ // LRU eviction: drop the least-recently-used entry
422
474
  if (maxSize > 0 && cache.size > maxSize) {
423
475
  cache.delete(cache.keys().next().value);
424
476
  }
@@ -12,6 +12,7 @@ import {
12
12
  escapeHtml, html, trust, TrustedHTML, uuid, camelCase, kebabCase,
13
13
  deepClone, deepMerge, isEqual, param, parseQuery,
14
14
  storage, session, EventBus, bus,
15
+ setPath,
15
16
  } from '../src/utils.js';
16
17
  import { createRouter, getRouter } from '../src/router.js';
17
18
  import { component, mount, mountAll, destroy, prefetch, getInstance } from '../src/component.js';
@@ -2861,7 +2862,7 @@ describe('Store', () => {
2861
2862
  actions: { inc(state) { state.count++; } }
2862
2863
  });
2863
2864
  const received = [];
2864
- store.subscribe('count', (val, old) => received.push({ val, old }));
2865
+ store.subscribe('count', (key, val, old) => received.push({ val, old }));
2865
2866
  store.dispatch('inc');
2866
2867
  store.dispatch('inc');
2867
2868
  expect(received).toEqual([
@@ -2876,7 +2877,7 @@ describe('Store', () => {
2876
2877
  actions: { bump(state) { state.x++; } }
2877
2878
  });
2878
2879
  const received = [];
2879
- const unsub = store.subscribe('x', (val) => received.push(val));
2880
+ const unsub = store.subscribe('x', (key, val) => received.push(val));
2880
2881
  store.dispatch('bump');
2881
2882
  unsub();
2882
2883
  store.dispatch('bump');
@@ -3062,7 +3063,7 @@ describe('Store', () => {
3062
3063
  });
3063
3064
  const received = [];
3064
3065
  store.subscribe('x', () => { throw new Error('sub error'); });
3065
- store.subscribe('x', (val) => received.push(val));
3066
+ store.subscribe('x', (key, val) => received.push(val));
3066
3067
  store.dispatch('bump');
3067
3068
  // The second subscriber still gets called because reportError is used (not re-throw)
3068
3069
  expect(received).toEqual([1]);
@@ -3093,7 +3094,7 @@ describe('Store', () => {
3093
3094
  state: { x: 0 }
3094
3095
  });
3095
3096
  const received = [];
3096
- store.subscribe('x', (val) => received.push(val));
3097
+ store.subscribe('x', (key, val) => received.push(val));
3097
3098
  store.state.x = 99;
3098
3099
  expect(received).toEqual([99]);
3099
3100
  });
@@ -3895,7 +3896,7 @@ describe('TrustedHTML and html template tag', () => {
3895
3896
 
3896
3897
 
3897
3898
  // ===========================================================================
3898
- // 24. Bug 9 `new` constructor globals reachable
3899
+ // 24. Bug 9 - `new` constructor globals reachable
3899
3900
  // ===========================================================================
3900
3901
  describe('new constructor globals (Bug 9)', () => {
3901
3902
  const eval_ = (expr, ctx = {}) => safeEval(expr, [ctx]);
@@ -3912,10 +3913,9 @@ describe('new constructor globals (Bug 9)', () => {
3912
3913
  expect(result.has(2)).toBe(true);
3913
3914
  });
3914
3915
 
3915
- it('new RegExp creates a RegExp', () => {
3916
+ it('new RegExp is blocked (ReDoS prevention)', () => {
3916
3917
  const result = eval_('new RegExp(pat, flags)', { pat: '^hello', flags: 'i' });
3917
- expect(result).toBeInstanceOf(RegExp);
3918
- expect(result.test('Hello world')).toBe(true);
3918
+ expect(result).toBeUndefined();
3919
3919
  });
3920
3920
 
3921
3921
  it('new URL creates a URL', () => {
@@ -3931,10 +3931,9 @@ describe('new constructor globals (Bug 9)', () => {
3931
3931
  expect(result.get('b')).toBe('2');
3932
3932
  });
3933
3933
 
3934
- it('new Error creates an Error', () => {
3934
+ it('new Error is blocked (info disclosure prevention)', () => {
3935
3935
  const result = eval_('new Error(msg)', { msg: 'test error' });
3936
- expect(result).toBeInstanceOf(Error);
3937
- expect(result.message).toBe('test error');
3936
+ expect(result).toBeUndefined();
3938
3937
  });
3939
3938
 
3940
3939
  it('Map and Set are accessible as identifiers for instanceof', () => {
@@ -3945,7 +3944,7 @@ describe('new constructor globals (Bug 9)', () => {
3945
3944
 
3946
3945
 
3947
3946
  // ===========================================================================
3948
- // 25. Bug 10 optional_call preserves `this` binding
3947
+ // 25. Bug 10 - optional_call preserves `this` binding
3949
3948
  // ===========================================================================
3950
3949
  describe('optional_call this binding (Bug 10)', () => {
3951
3950
  const eval_ = (expr, ctx = {}) => safeEval(expr, [ctx]);
@@ -3974,7 +3973,7 @@ describe('optional_call this binding (Bug 10)', () => {
3974
3973
 
3975
3974
 
3976
3975
  // ===========================================================================
3977
- // 26. Bug 11 HTTP abort vs timeout distinction
3976
+ // 26. Bug 11 - HTTP abort vs timeout distinction
3978
3977
  // ===========================================================================
3979
3978
  describe('HTTP abort vs timeout message (Bug 11)', () => {
3980
3979
  it('user abort says "aborted" not "timeout"', async () => {
@@ -4000,7 +3999,7 @@ describe('HTTP abort vs timeout message (Bug 11)', () => {
4000
3999
 
4001
4000
 
4002
4001
  // ===========================================================================
4003
- // 27. Bug 12 isEqual circular reference protection
4002
+ // 27. Bug 12 - isEqual circular reference protection
4004
4003
  // ===========================================================================
4005
4004
  describe('isEqual circular reference protection (Bug 12)', () => {
4006
4005
  it('does not stack overflow on circular objects', () => {
@@ -4008,7 +4007,7 @@ describe('isEqual circular reference protection (Bug 12)', () => {
4008
4007
  a.self = a;
4009
4008
  const b = { x: 1 };
4010
4009
  b.self = b;
4011
- // Should not throw just return true (both are circular in the same shape)
4010
+ // Should not throw - just return true (both are circular in the same shape)
4012
4011
  expect(() => isEqual(a, b)).not.toThrow();
4013
4012
  expect(isEqual(a, b)).toBe(true);
4014
4013
  });
@@ -4028,3 +4027,132 @@ describe('isEqual circular reference protection (Bug 12)', () => {
4028
4027
  expect(isEqual([1, 2], [1, 3])).toBe(false);
4029
4028
  });
4030
4029
  });
4030
+
4031
+
4032
+ // ===========================================================================
4033
+ // SECURITY AUDIT v2 - Prototype Pollution, ReDoS, Expression Safety
4034
+ // ===========================================================================
4035
+
4036
+ describe('Security: deepMerge prototype pollution prevention', () => {
4037
+ it('blocks __proto__ key from being merged', () => {
4038
+ const target = {};
4039
+ const malicious = JSON.parse('{"__proto__": {"polluted": true}}');
4040
+ deepMerge(target, malicious);
4041
+ expect(target.polluted).toBeUndefined();
4042
+ expect(({}).polluted).toBeUndefined();
4043
+ });
4044
+
4045
+ it('blocks constructor key from being merged', () => {
4046
+ const target = {};
4047
+ deepMerge(target, { constructor: { prototype: { polluted: true } } });
4048
+ expect(({}).polluted).toBeUndefined();
4049
+ });
4050
+
4051
+ it('blocks prototype key from being merged', () => {
4052
+ const target = {};
4053
+ deepMerge(target, { prototype: { polluted: true } });
4054
+ expect(target.prototype).toBeUndefined();
4055
+ });
4056
+
4057
+ it('still merges safe keys normally', () => {
4058
+ const result = deepMerge({}, { a: 1, b: { c: 2 } });
4059
+ expect(result).toEqual({ a: 1, b: { c: 2 } });
4060
+ });
4061
+
4062
+ it('blocks nested __proto__ pollution attempt', () => {
4063
+ const target = { nested: {} };
4064
+ const malicious = JSON.parse('{"nested": {"__proto__": {"deep": true}}}');
4065
+ deepMerge(target, malicious);
4066
+ expect(({}).deep).toBeUndefined();
4067
+ expect(target.nested.deep).toBeUndefined();
4068
+ });
4069
+ });
4070
+
4071
+ describe('Security: setPath prototype pollution prevention', () => {
4072
+ it('blocks __proto__ in path segments', () => {
4073
+ const obj = {};
4074
+ setPath(obj, '__proto__.polluted', true);
4075
+ expect(({}).polluted).toBeUndefined();
4076
+ });
4077
+
4078
+ it('blocks constructor in path segments', () => {
4079
+ const obj = {};
4080
+ setPath(obj, 'constructor.prototype.polluted', true);
4081
+ expect(({}).polluted).toBeUndefined();
4082
+ });
4083
+
4084
+ it('blocks prototype as final key', () => {
4085
+ const obj = {};
4086
+ setPath(obj, 'prototype', { evil: true });
4087
+ expect(obj.prototype).toBeUndefined();
4088
+ });
4089
+
4090
+ it('still sets safe paths normally', () => {
4091
+ const obj = {};
4092
+ setPath(obj, 'a.b.c', 42);
4093
+ expect(obj.a.b.c).toBe(42);
4094
+ });
4095
+ });
4096
+
4097
+ describe('Security: expression evaluator - blocked constructors', () => {
4098
+ it('blocks RegExp constructor (ReDoS prevention)', () => {
4099
+ expect(eval_('new RegExp(".*")')).toBeUndefined();
4100
+ expect(eval_('RegExp')).toBeUndefined();
4101
+ });
4102
+
4103
+ it('blocks Error constructor (info leak prevention)', () => {
4104
+ expect(eval_('new Error("test")')).toBeUndefined();
4105
+ });
4106
+
4107
+ it('blocks Function constructor', () => {
4108
+ expect(eval_('new Function("return 1")')).toBeUndefined();
4109
+ });
4110
+
4111
+ it('still allows safe constructors', () => {
4112
+ expect(eval_('new Date(2024, 0, 1)')).toBeInstanceOf(Date);
4113
+ expect(eval_('new Map')).toBeInstanceOf(Map);
4114
+ expect(eval_('new Set')).toBeInstanceOf(Set);
4115
+ expect(eval_('new Array(3)')).toBeInstanceOf(Array);
4116
+ expect(eval_('new URL("https://example.com")')).toBeInstanceOf(URL);
4117
+ });
4118
+ });
4119
+
4120
+ describe('Security: expression evaluator - blocked property access', () => {
4121
+ it('blocks __proto__ access', () => {
4122
+ expect(eval_('obj.__proto__', { obj: {} })).toBeUndefined();
4123
+ });
4124
+
4125
+ it('blocks constructor access', () => {
4126
+ expect(eval_('obj.constructor', { obj: {} })).toBeUndefined();
4127
+ });
4128
+
4129
+ it('blocks prototype access', () => {
4130
+ expect(eval_('obj.prototype', { obj: function(){} })).toBeUndefined();
4131
+ });
4132
+
4133
+ it('blocks __defineGetter__ access', () => {
4134
+ expect(eval_('obj.__defineGetter__', { obj: {} })).toBeUndefined();
4135
+ });
4136
+
4137
+ it('blocks call/apply/bind', () => {
4138
+ expect(eval_('fn.call', { fn: () => {} })).toBeUndefined();
4139
+ expect(eval_('fn.apply', { fn: () => {} })).toBeUndefined();
4140
+ expect(eval_('fn.bind', { fn: () => {} })).toBeUndefined();
4141
+ });
4142
+ });
4143
+
4144
+ describe('Security: template expression HTML escaping', () => {
4145
+ it('escapeHtml escapes script tags', () => {
4146
+ expect(escapeHtml('<script>alert(1)</script>')).toBe('&lt;script&gt;alert(1)&lt;/script&gt;');
4147
+ });
4148
+
4149
+ it('escapeHtml escapes quotes and ampersands', () => {
4150
+ expect(escapeHtml('"&\'')).toBe('&quot;&amp;&#39;');
4151
+ });
4152
+
4153
+ it('escapeHtml handles non-string input', () => {
4154
+ expect(escapeHtml(42)).toBe('42');
4155
+ expect(escapeHtml(null)).toBe('null');
4156
+ expect(escapeHtml(undefined)).toBe('undefined');
4157
+ });
4158
+ });