form0-core 0.1.8 → 0.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "form0-core",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
@@ -1,7 +1,12 @@
1
1
  import { runExpression } from './evaluator.js';
2
- import { flattenFields } from '../utilities/field-helpers.js';
3
2
  import { ContextResolver } from './context-resolver.js';
4
3
  import { WarningSystem } from './warning-system.js';
4
+ import {
5
+ buildCalculationDependencyPlan,
6
+ buildCalculationExecutionContext,
7
+ extractStaticFieldReferences,
8
+ } from '../utilities/calculation-dependencies.js';
9
+ import { areStructuredValuesEqual } from '../utilities/value-equality.js';
5
10
 
6
11
  export function evaluateCalculatedFields(
7
12
  schema,
@@ -10,71 +15,86 @@ export function evaluateCalculatedFields(
10
15
  securityConfig,
11
16
  contextResolver = null,
12
17
  warningSystem = null,
13
- runtimeDiagnostics = null
18
+ runtimeDiagnostics = null,
19
+ calculationPlan = null
14
20
  ) {
15
- const fields = flattenFields(schema.elements);
16
-
17
- // Initialize context resolver and warning system if not provided (backward compatibility)
18
21
  const resolver = contextResolver || new ContextResolver(schema);
19
22
  const warnings = warningSystem || new WarningSystem();
23
+ const plan = calculationPlan || buildCalculationDependencyPlan(schema, resolver);
20
24
 
21
- fields.forEach((field) => {
22
- if (field.type === 'CalculatedField' && field.calculate) {
23
- try {
24
- // Create execution context for this calculation
25
- const executionContext = {
26
- type: 'calculation',
27
- fieldName: field.data_name,
28
- parentPath: resolver.getFieldInfo(field.data_name)?.parentPath || [],
29
- };
30
-
31
- const context = buildScopedContext(
32
- values,
33
- helpers,
34
- executionContext,
35
- resolver,
36
- warnings,
37
- field.calculate
38
- );
39
- values[field.data_name] = runExpression(
40
- field.calculate,
41
- context,
42
- securityConfig,
43
- false,
44
- schema,
45
- {
46
- suppressConsoleWarning: Array.isArray(runtimeDiagnostics),
47
- onError: (error) => {
48
- if (!Array.isArray(runtimeDiagnostics)) {
49
- return;
50
- }
51
-
52
- runtimeDiagnostics.push({
53
- fieldName: field.data_name,
54
- message:
55
- error instanceof Error && error.message
56
- ? error.message
57
- : 'Unknown calculation runtime error.',
58
- });
59
- },
60
- }
61
- );
62
- } catch (e) {
63
- if (Array.isArray(runtimeDiagnostics)) {
64
- runtimeDiagnostics.push({
65
- fieldName: field.data_name,
66
- message:
67
- e instanceof Error && e.message
68
- ? e.message
69
- : 'Unknown calculation runtime error.',
70
- });
71
- }
72
- if (!Array.isArray(runtimeDiagnostics)) {
73
- console.warn(`Calculation failed for ${field.data_name}:`, e.message);
74
- }
75
- }
25
+ emitDependencyPlanWarnings(plan, warnings, runtimeDiagnostics);
26
+
27
+ if (plan.totalCalculatedFieldCount === 0) {
28
+ return;
29
+ }
30
+
31
+ const blockedFieldNames = blockCyclicCalculatedFields(plan, values);
32
+ const evaluableFieldNames = plan.orderedFieldNames.filter(
33
+ (fieldName) => !blockedFieldNames.has(fieldName)
34
+ );
35
+
36
+ if (evaluableFieldNames.length === 0) {
37
+ return;
38
+ }
39
+
40
+ if (plan.hasDynamicDependencies) {
41
+ evaluateFieldSequenceUntilStable(
42
+ evaluableFieldNames,
43
+ plan,
44
+ schema,
45
+ values,
46
+ helpers,
47
+ securityConfig,
48
+ resolver,
49
+ warnings,
50
+ runtimeDiagnostics
51
+ );
52
+ return;
53
+ }
54
+
55
+ plan.orderedComponentIds.forEach((componentId) => {
56
+ const component = plan.componentsById.get(componentId);
57
+ if (!component || component.fieldNames.length === 0) {
58
+ return;
59
+ }
60
+
61
+ if (component.isCyclic) {
62
+ return;
63
+ }
64
+
65
+ const fieldName = component.fieldNames[0];
66
+ const node = plan.nodesByDataName.get(fieldName);
67
+ if (node?.field) {
68
+ evaluateCalculatedField(
69
+ node.field,
70
+ schema,
71
+ values,
72
+ helpers,
73
+ securityConfig,
74
+ resolver,
75
+ warnings,
76
+ runtimeDiagnostics
77
+ );
78
+ }
79
+ });
80
+ }
81
+
82
+ function blockCyclicCalculatedFields(plan, values) {
83
+ const blockedFieldNames = new Set();
84
+
85
+ plan.cyclicComponentIds.forEach((componentId) => {
86
+ const component = plan.componentsById.get(componentId);
87
+ if (!component?.isCyclic) {
88
+ return;
76
89
  }
90
+
91
+ component.fieldNames.forEach((fieldName) => {
92
+ blockedFieldNames.add(fieldName);
93
+ values[fieldName] = null;
94
+ });
77
95
  });
96
+
97
+ return blockedFieldNames;
78
98
  }
79
99
 
80
100
  function buildScopedContext(
@@ -87,43 +107,32 @@ function buildScopedContext(
87
107
  ) {
88
108
  const ctx = { ...helpers };
89
109
 
90
- // Find which fields are actually referenced in the expression
91
- const referencedFields = extractFieldReferences(expressionCode);
92
-
93
- // Track problematic fields that are actually accessed
110
+ const referencedFields = extractStaticFieldReferences(expressionCode);
94
111
  const restrictedAccessedFields = [];
95
112
  const notFoundAccessedFields = [];
96
113
 
97
- // Add scoped field access
98
114
  for (const [fieldName, value] of Object.entries(values)) {
99
115
  const accessLevel = contextResolver.resolveFieldAccess(executionContext, fieldName);
100
116
 
101
117
  if (accessLevel === 'accessible') {
102
118
  ctx[`$${fieldName}`] = value;
103
119
  } else if (accessLevel === 'restricted') {
104
- // Only add restricted fields to context if they're actually referenced
105
120
  if (referencedFields.has(fieldName)) {
106
121
  ctx[`$${fieldName}`] = undefined;
107
122
  restrictedAccessedFields.push(fieldName);
108
123
  }
109
- // If not referenced, don't add to context at all
110
124
  }
111
- // 'not_found' fields are not added to context at all
112
125
  }
113
126
 
114
- // Check for not_found fields that are actually referenced
115
127
  for (const fieldName of referencedFields) {
116
- // Check if this field was processed above (exists in values)
117
128
  if (!(fieldName in values)) {
118
129
  const accessLevel = contextResolver.resolveFieldAccess(executionContext, fieldName);
119
130
  if (accessLevel === 'not_found') {
120
131
  notFoundAccessedFields.push(fieldName);
121
- // Don't add to context - it will be undefined when accessed
122
132
  }
123
133
  }
124
134
  }
125
135
 
126
- // Emit warnings for restricted fields that are actually accessed
127
136
  restrictedAccessedFields.forEach((fieldName) => {
128
137
  const warning = contextResolver.generateAccessWarning(
129
138
  executionContext,
@@ -133,7 +142,6 @@ function buildScopedContext(
133
142
  warningSystem.emitWarning(warning);
134
143
  });
135
144
 
136
- // Emit warnings for not_found fields that are actually accessed
137
145
  notFoundAccessedFields.forEach((fieldName) => {
138
146
  const warning = contextResolver.generateAccessWarning(executionContext, fieldName, 'not_found');
139
147
  warningSystem.emitWarning(warning);
@@ -142,93 +150,226 @@ function buildScopedContext(
142
150
  return ctx;
143
151
  }
144
152
 
145
- /**
146
- * Extract field references from expression code (simple regex-based approach)
147
- * Skips field references inside EVAL() calls to avoid false positives from dynamic field construction
148
- * @param {string} code - The expression/code to analyze
149
- * @returns {Set<string>} Set of field names referenced in the code
150
- */
151
- function extractFieldReferences(code) {
152
- const fieldReferences = new Set();
153
-
154
- // Remove content inside EVAL() calls to avoid false positives
155
- // This handles complex patterns like EVAL('string' + variable + 'more')
156
- const cleanedCode = removeEvalContents(code);
157
-
158
- // Simple regex to find $fieldname patterns in the cleaned code
159
- // This matches $ followed by valid JavaScript identifier characters
160
- const fieldRegex = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
161
- let match;
162
-
163
- while ((match = fieldRegex.exec(cleanedCode)) !== null) {
164
- fieldReferences.add(match[1]); // Add the field name (without $)
153
+ function pushRuntimeDiagnostic(
154
+ runtimeDiagnostics,
155
+ fieldName,
156
+ message,
157
+ severity = 'error',
158
+ dedupeKey = null
159
+ ) {
160
+ if (!Array.isArray(runtimeDiagnostics)) {
161
+ return;
162
+ }
163
+
164
+ if (
165
+ dedupeKey &&
166
+ runtimeDiagnostics.some(
167
+ (diagnostic) =>
168
+ diagnostic?.fieldName === fieldName &&
169
+ diagnostic?.message === message &&
170
+ diagnostic?.dedupeKey === dedupeKey
171
+ )
172
+ ) {
173
+ return;
165
174
  }
166
175
 
167
- return fieldReferences;
176
+ runtimeDiagnostics.push({
177
+ fieldName,
178
+ message,
179
+ severity,
180
+ dedupeKey,
181
+ });
168
182
  }
169
183
 
170
- /**
171
- * Remove EVAL() function calls and their contents from code
172
- * Handles nested parentheses and complex expressions inside EVAL()
173
- * @param {string} code - The code to clean
174
- * @returns {string} Code with EVAL() contents removed
175
- */
176
- function removeEvalContents(code) {
177
- let result = '';
178
- let i = 0;
179
-
180
- while (i < code.length) {
181
- // Look for EVAL pattern
182
- const evalMatch = code.substring(i).match(/^EVAL\s*\(/);
183
- if (evalMatch) {
184
- // Found EVAL(, add "EVAL()" to result and skip the entire call
185
- result += 'EVAL()';
186
- i += evalMatch[0].length;
187
-
188
- // Skip everything until we find the matching closing parenthesis
189
- let parenCount = 1;
190
- let inSingleQuote = false;
191
- let inDoubleQuote = false;
192
- let inBacktick = false;
193
-
194
- while (i < code.length && parenCount > 0) {
195
- const char = code[i];
196
-
197
- // Handle string literals (ignore parentheses inside strings)
198
- if (char === "'" && !inDoubleQuote && !inBacktick) {
199
- inSingleQuote = !inSingleQuote;
200
- } else if (char === '"' && !inSingleQuote && !inBacktick) {
201
- inDoubleQuote = !inDoubleQuote;
202
- } else if (char === '`' && !inSingleQuote && !inDoubleQuote) {
203
- inBacktick = !inBacktick;
204
- }
205
-
206
- // Only count parentheses when not inside strings
207
- if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
208
- if (char === '(') {
209
- parenCount++;
210
- } else if (char === ')') {
211
- parenCount--;
212
- }
213
- }
214
-
215
- i++;
216
- }
217
- } else {
218
- // Not an EVAL call, add character to result
219
- result += code[i];
220
- i++;
184
+ function emitDependencyPlanWarnings(plan, warningSystem, runtimeDiagnostics) {
185
+ plan.dynamicFieldNames.forEach((fieldName) => {
186
+ const message = `CalculatedField '${fieldName}' uses dynamic field access. Runtime evaluation falls back to bounded stabilization.`;
187
+ warningSystem.emitWarning({
188
+ type: 'calculation_dependency',
189
+ reason: 'dynamic_dependencies',
190
+ message,
191
+ suggestion: 'Prefer direct $field references when possible to enable dependency ordering.',
192
+ executionContext: {
193
+ type: 'calculation',
194
+ fieldName,
195
+ },
196
+ fieldContext: {
197
+ fieldName,
198
+ dynamic: true,
199
+ },
200
+ });
201
+ pushRuntimeDiagnostic(
202
+ runtimeDiagnostics,
203
+ fieldName,
204
+ message,
205
+ 'warning',
206
+ `dynamic:${fieldName}`
207
+ );
208
+ });
209
+
210
+ plan.cyclicComponentIds.forEach((componentId) => {
211
+ const component = plan.componentsById.get(componentId);
212
+ if (!component?.isCyclic) {
213
+ return;
221
214
  }
215
+
216
+ const fieldNames = component.fieldNames;
217
+ const message = `CalculatedField dependency cycle detected: ${fieldNames.join(' -> ')}. Runtime evaluation is disabled for the involved fields until the cycle is removed.`;
218
+ warningSystem.emitWarning({
219
+ type: 'calculation_dependency',
220
+ reason: 'cyclic_dependencies',
221
+ severity: 'error',
222
+ message,
223
+ suggestion:
224
+ 'Remove the cycle or break it with a source field so calculated values can be evaluated deterministically.',
225
+ executionContext: {
226
+ type: 'calculation',
227
+ fieldName: fieldNames[0] || null,
228
+ },
229
+ fieldContext: {
230
+ fieldNames: [...fieldNames],
231
+ cyclic: true,
232
+ },
233
+ });
234
+
235
+ fieldNames.forEach((fieldName) => {
236
+ pushRuntimeDiagnostic(
237
+ runtimeDiagnostics,
238
+ fieldName,
239
+ message,
240
+ 'error',
241
+ `cycle:${componentId}`
242
+ );
243
+ });
244
+ });
245
+ }
246
+
247
+ function evaluateCalculatedField(
248
+ field,
249
+ schema,
250
+ values,
251
+ helpers,
252
+ securityConfig,
253
+ resolver,
254
+ warnings,
255
+ runtimeDiagnostics
256
+ ) {
257
+ if (!field?.data_name || !field.calculate) {
258
+ return { changed: false, hasError: false };
222
259
  }
223
260
 
224
- return result;
261
+ try {
262
+ const executionContext = buildCalculationExecutionContext(schema, field.data_name, resolver);
263
+ const context = buildScopedContext(
264
+ values,
265
+ helpers,
266
+ executionContext,
267
+ resolver,
268
+ warnings,
269
+ field.calculate
270
+ );
271
+ const previousValue = values[field.data_name];
272
+ const nextValue = runExpression(field.calculate, context, securityConfig, false, schema, {
273
+ suppressConsoleWarning: Array.isArray(runtimeDiagnostics),
274
+ onError: (error) => {
275
+ pushRuntimeDiagnostic(
276
+ runtimeDiagnostics,
277
+ field.data_name,
278
+ error instanceof Error && error.message
279
+ ? error.message
280
+ : 'Unknown calculation runtime error.'
281
+ );
282
+ },
283
+ });
284
+ values[field.data_name] = nextValue;
285
+
286
+ return {
287
+ changed: !areStructuredValuesEqual(previousValue, nextValue),
288
+ hasError: false,
289
+ };
290
+ } catch (error) {
291
+ pushRuntimeDiagnostic(
292
+ runtimeDiagnostics,
293
+ field.data_name,
294
+ error instanceof Error && error.message ? error.message : 'Unknown calculation runtime error.'
295
+ );
296
+
297
+ if (!Array.isArray(runtimeDiagnostics)) {
298
+ console.warn(`Calculation failed for ${field.data_name}:`, error?.message);
299
+ }
300
+
301
+ return { changed: false, hasError: true };
302
+ }
225
303
  }
226
304
 
227
- // Legacy function for backward compatibility
228
- function buildContext(values, helpers) {
229
- const ctx = {};
230
- for (const key in values) {
231
- ctx[`$${key}`] = values[key];
305
+ function evaluateFieldSequenceUntilStable(
306
+ fieldNames,
307
+ plan,
308
+ schema,
309
+ values,
310
+ helpers,
311
+ securityConfig,
312
+ resolver,
313
+ warnings,
314
+ runtimeDiagnostics
315
+ ) {
316
+ const maxPasses = Math.max(2, fieldNames.length);
317
+ let stillChangingFieldNames = [];
318
+
319
+ for (let pass = 0; pass < maxPasses; pass += 1) {
320
+ stillChangingFieldNames = [];
321
+
322
+ fieldNames.forEach((fieldName) => {
323
+ const node = plan.nodesByDataName.get(fieldName);
324
+ if (!node?.field) {
325
+ return;
326
+ }
327
+
328
+ const result = evaluateCalculatedField(
329
+ node.field,
330
+ schema,
331
+ values,
332
+ helpers,
333
+ securityConfig,
334
+ resolver,
335
+ warnings,
336
+ runtimeDiagnostics
337
+ );
338
+
339
+ if (result.changed) {
340
+ stillChangingFieldNames.push(fieldName);
341
+ }
342
+ });
343
+
344
+ if (stillChangingFieldNames.length === 0) {
345
+ return;
346
+ }
232
347
  }
233
- return { ...ctx, ...helpers };
348
+
349
+ const message = `Calculated fields did not stabilize within ${maxPasses} evaluation passes: ${stillChangingFieldNames.join(', ')}.`;
350
+ warnings.emitWarning({
351
+ type: 'calculation_dependency',
352
+ reason: 'non_converging_runtime',
353
+ message,
354
+ suggestion:
355
+ 'Review the involved CalculatedField expressions for dependency cycles or dynamic references that never settle.',
356
+ executionContext: {
357
+ type: 'calculation',
358
+ fieldName: stillChangingFieldNames[0] || null,
359
+ },
360
+ fieldContext: {
361
+ fieldNames: [...stillChangingFieldNames],
362
+ maxPasses,
363
+ },
364
+ });
365
+
366
+ stillChangingFieldNames.forEach((fieldName) => {
367
+ pushRuntimeDiagnostic(
368
+ runtimeDiagnostics,
369
+ fieldName,
370
+ message,
371
+ 'warning',
372
+ `non-converging:${fieldNames.join(',')}`
373
+ );
374
+ });
234
375
  }
@@ -12,6 +12,7 @@ import { FIELD_SPECS } from '../schema/field-specs.js';
12
12
  import { ContextResolver } from './context-resolver.js';
13
13
  import { WarningSystem } from './warning-system.js';
14
14
  import { expandBuildingPlanSchema } from '../schema/building-plan-expander.js';
15
+ import { buildCalculationDependencyPlan } from '../utilities/calculation-dependencies.js';
15
16
 
16
17
  export function createFormEngine({
17
18
  schema,
@@ -94,6 +95,7 @@ export function createFormEngine({
94
95
 
95
96
  // Initialize context resolution system
96
97
  const contextResolver = new ContextResolver(form);
98
+ const calculationPlan = buildCalculationDependencyPlan(form, contextResolver);
97
99
  const sharedWarningSystem = warningSystem || new WarningSystem();
98
100
 
99
101
  // Initialize event system with context resolution
@@ -128,7 +130,8 @@ export function createFormEngine({
128
130
  security,
129
131
  contextResolver,
130
132
  sharedWarningSystem,
131
- runtimeDiagnostics
133
+ runtimeDiagnostics,
134
+ calculationPlan
132
135
  );
133
136
  evaluateRequirement(form, values, required);
134
137
  evaluateVisibility(form, values, visible);
@@ -0,0 +1,546 @@
1
+ import { ContextResolver } from '../engine/context-resolver.js';
2
+ import { flattenFields } from './field-helpers.js';
3
+
4
+ function sortFieldNames(fieldNames, orderByFieldName) {
5
+ return [...fieldNames].sort(
6
+ (left, right) => orderByFieldName.get(left) - orderByFieldName.get(right)
7
+ );
8
+ }
9
+
10
+ function buildStronglyConnectedComponents(nodesByDataName, orderByFieldName) {
11
+ const indices = new Map();
12
+ const lowLinks = new Map();
13
+ const stack = [];
14
+ const onStack = new Set();
15
+ const components = [];
16
+ let currentIndex = 0;
17
+
18
+ function strongConnect(fieldName) {
19
+ indices.set(fieldName, currentIndex);
20
+ lowLinks.set(fieldName, currentIndex);
21
+ currentIndex += 1;
22
+ stack.push(fieldName);
23
+ onStack.add(fieldName);
24
+
25
+ const node = nodesByDataName.get(fieldName);
26
+ if (node) {
27
+ for (const dependency of node.dependencies) {
28
+ if (!indices.has(dependency)) {
29
+ strongConnect(dependency);
30
+ lowLinks.set(fieldName, Math.min(lowLinks.get(fieldName), lowLinks.get(dependency)));
31
+ } else if (onStack.has(dependency)) {
32
+ lowLinks.set(fieldName, Math.min(lowLinks.get(fieldName), indices.get(dependency)));
33
+ }
34
+ }
35
+ }
36
+
37
+ if (lowLinks.get(fieldName) !== indices.get(fieldName)) {
38
+ return;
39
+ }
40
+
41
+ const componentFieldNames = [];
42
+ while (stack.length > 0) {
43
+ const stackFieldName = stack.pop();
44
+ onStack.delete(stackFieldName);
45
+ componentFieldNames.push(stackFieldName);
46
+ if (stackFieldName === fieldName) {
47
+ break;
48
+ }
49
+ }
50
+
51
+ components.push(sortFieldNames(componentFieldNames, orderByFieldName));
52
+ }
53
+
54
+ const sortedFieldNames = sortFieldNames(nodesByDataName.keys(), orderByFieldName);
55
+ sortedFieldNames.forEach((fieldName) => {
56
+ if (!indices.has(fieldName)) {
57
+ strongConnect(fieldName);
58
+ }
59
+ });
60
+
61
+ return components;
62
+ }
63
+
64
+ function topologicallyOrderComponents(componentIds, componentEdges) {
65
+ const inDegree = new Map(componentIds.map((componentId) => [componentId, 0]));
66
+
67
+ componentEdges.forEach((targets) => {
68
+ targets.forEach((targetId) => {
69
+ inDegree.set(targetId, (inDegree.get(targetId) || 0) + 1);
70
+ });
71
+ });
72
+
73
+ const queue = componentIds.filter((componentId) => (inDegree.get(componentId) || 0) === 0);
74
+ const ordered = [];
75
+
76
+ while (queue.length > 0) {
77
+ const componentId = queue.shift();
78
+ ordered.push(componentId);
79
+
80
+ for (const targetId of componentEdges.get(componentId) || []) {
81
+ const nextDegree = (inDegree.get(targetId) || 0) - 1;
82
+ inDegree.set(targetId, nextDegree);
83
+ if (nextDegree === 0) {
84
+ queue.push(targetId);
85
+ }
86
+ }
87
+ }
88
+
89
+ if (ordered.length === componentIds.length) {
90
+ return ordered;
91
+ }
92
+
93
+ return [...componentIds];
94
+ }
95
+
96
+ export function normalizeCalculationFormSchema(schema) {
97
+ if (schema && Array.isArray(schema.elements)) {
98
+ return schema;
99
+ }
100
+
101
+ if (schema && schema.form && Array.isArray(schema.form.elements)) {
102
+ return schema.form;
103
+ }
104
+
105
+ throw new Error('Expected a form schema or a root schema containing form.elements');
106
+ }
107
+
108
+ function isEscapedCharacter(source, index) {
109
+ let backslashCount = 0;
110
+ let cursor = index - 1;
111
+
112
+ while (cursor >= 0 && source[cursor] === '\\') {
113
+ backslashCount += 1;
114
+ cursor -= 1;
115
+ }
116
+
117
+ return backslashCount % 2 === 1;
118
+ }
119
+
120
+ function skipWhitespace(source, state) {
121
+ while (state.index < source.length && /\s/.test(source[state.index])) {
122
+ state.index += 1;
123
+ }
124
+ }
125
+
126
+ function parseStaticStringLiteral(source, state) {
127
+ const quote = source[state.index];
128
+ if (quote !== "'" && quote !== '"' && quote !== '`') {
129
+ return null;
130
+ }
131
+
132
+ state.index += 1;
133
+ let value = '';
134
+
135
+ while (state.index < source.length) {
136
+ const char = source[state.index];
137
+
138
+ if (char === quote && !isEscapedCharacter(source, state.index)) {
139
+ state.index += 1;
140
+ return value;
141
+ }
142
+
143
+ if (quote === '`' && char === '$' && source[state.index + 1] === '{') {
144
+ return null;
145
+ }
146
+
147
+ if (char === '\\' && state.index + 1 < source.length) {
148
+ const escapedChar = source[state.index + 1];
149
+ const escapeMap = {
150
+ n: '\n',
151
+ r: '\r',
152
+ t: '\t',
153
+ "'": "'",
154
+ '"': '"',
155
+ '`': '`',
156
+ '\\': '\\',
157
+ };
158
+
159
+ value += escapeMap[escapedChar] ?? escapedChar;
160
+ state.index += 2;
161
+ continue;
162
+ }
163
+
164
+ value += char;
165
+ state.index += 1;
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ function parseStaticStringTerm(source, state) {
172
+ skipWhitespace(source, state);
173
+
174
+ if (source[state.index] === '(') {
175
+ state.index += 1;
176
+ const nestedValue = parseStaticStringConcatenation(source, state);
177
+ if (nestedValue === null) {
178
+ return null;
179
+ }
180
+
181
+ skipWhitespace(source, state);
182
+ if (source[state.index] !== ')') {
183
+ return null;
184
+ }
185
+
186
+ state.index += 1;
187
+ return nestedValue;
188
+ }
189
+
190
+ return parseStaticStringLiteral(source, state);
191
+ }
192
+
193
+ function parseStaticStringConcatenation(source, state) {
194
+ const firstValue = parseStaticStringTerm(source, state);
195
+ if (firstValue === null) {
196
+ return null;
197
+ }
198
+
199
+ let result = firstValue;
200
+
201
+ while (true) {
202
+ skipWhitespace(source, state);
203
+ if (source[state.index] !== '+') {
204
+ return result;
205
+ }
206
+
207
+ state.index += 1;
208
+ const nextValue = parseStaticStringTerm(source, state);
209
+ if (nextValue === null) {
210
+ return null;
211
+ }
212
+
213
+ result += nextValue;
214
+ }
215
+ }
216
+
217
+ function resolveStaticStringExpression(source) {
218
+ if (typeof source !== 'string') {
219
+ return null;
220
+ }
221
+
222
+ const state = { index: 0 };
223
+ skipWhitespace(source, state);
224
+
225
+ if (state.index >= source.length) {
226
+ return null;
227
+ }
228
+
229
+ const result = parseStaticStringConcatenation(source, state);
230
+ if (result === null) {
231
+ return null;
232
+ }
233
+
234
+ skipWhitespace(source, state);
235
+ return state.index === source.length ? result : null;
236
+ }
237
+
238
+ function extractEvalCallArguments(code) {
239
+ const source = typeof code === 'string' ? code : '';
240
+ const argumentsList = [];
241
+ let i = 0;
242
+
243
+ while (i < source.length) {
244
+ const evalMatch = source.substring(i).match(/^EVAL\s*\(/);
245
+ if (!evalMatch) {
246
+ i += 1;
247
+ continue;
248
+ }
249
+
250
+ i += evalMatch[0].length;
251
+ const startIndex = i;
252
+
253
+ let parenCount = 1;
254
+ let inSingleQuote = false;
255
+ let inDoubleQuote = false;
256
+ let inBacktick = false;
257
+
258
+ while (i < source.length && parenCount > 0) {
259
+ const char = source[i];
260
+
261
+ if (char === "'" && !inDoubleQuote && !inBacktick && !isEscapedCharacter(source, i)) {
262
+ inSingleQuote = !inSingleQuote;
263
+ } else if (
264
+ char === '"' &&
265
+ !inSingleQuote &&
266
+ !inBacktick &&
267
+ !isEscapedCharacter(source, i)
268
+ ) {
269
+ inDoubleQuote = !inDoubleQuote;
270
+ } else if (
271
+ char === '`' &&
272
+ !inSingleQuote &&
273
+ !inDoubleQuote &&
274
+ !isEscapedCharacter(source, i)
275
+ ) {
276
+ inBacktick = !inBacktick;
277
+ }
278
+
279
+ if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
280
+ if (char === '(') {
281
+ parenCount += 1;
282
+ } else if (char === ')') {
283
+ parenCount -= 1;
284
+ if (parenCount === 0) {
285
+ argumentsList.push(source.slice(startIndex, i));
286
+ }
287
+ }
288
+ }
289
+
290
+ i += 1;
291
+ }
292
+ }
293
+
294
+ return argumentsList;
295
+ }
296
+
297
+ function resolveStaticEvalFieldReference(expression) {
298
+ let currentExpression = typeof expression === 'string' ? expression : null;
299
+
300
+ for (let depth = 0; depth < 2 && currentExpression; depth += 1) {
301
+ const resolvedString = resolveStaticStringExpression(currentExpression);
302
+ if (resolvedString === null) {
303
+ return null;
304
+ }
305
+
306
+ const fieldReferenceMatch = resolvedString.match(/^\$([a-zA-Z_][a-zA-Z0-9_]*)$/);
307
+ if (fieldReferenceMatch) {
308
+ return fieldReferenceMatch[1];
309
+ }
310
+
311
+ currentExpression = resolvedString;
312
+ }
313
+
314
+ return null;
315
+ }
316
+
317
+ function extractStaticEvalFieldReferences(code) {
318
+ const fieldReferences = new Set();
319
+
320
+ extractEvalCallArguments(code).forEach((argumentSource) => {
321
+ const fieldName = resolveStaticEvalFieldReference(argumentSource);
322
+ if (fieldName) {
323
+ fieldReferences.add(fieldName);
324
+ }
325
+ });
326
+
327
+ return fieldReferences;
328
+ }
329
+
330
+ export function hasDynamicCalculationDependencies(code) {
331
+ const evalArguments = extractEvalCallArguments(code);
332
+ if (evalArguments.length === 0) {
333
+ return false;
334
+ }
335
+
336
+ return evalArguments.some((argumentSource) => !resolveStaticEvalFieldReference(argumentSource));
337
+ }
338
+
339
+ export function removeEvalContents(code) {
340
+ const source = typeof code === 'string' ? code : '';
341
+ let result = '';
342
+ let i = 0;
343
+
344
+ while (i < source.length) {
345
+ const evalMatch = source.substring(i).match(/^EVAL\s*\(/);
346
+ if (evalMatch) {
347
+ result += 'EVAL()';
348
+ i += evalMatch[0].length;
349
+
350
+ let parenCount = 1;
351
+ let inSingleQuote = false;
352
+ let inDoubleQuote = false;
353
+ let inBacktick = false;
354
+
355
+ while (i < source.length && parenCount > 0) {
356
+ const char = source[i];
357
+
358
+ if (char === "'" && !inDoubleQuote && !inBacktick) {
359
+ inSingleQuote = !inSingleQuote;
360
+ } else if (char === '"' && !inSingleQuote && !inBacktick) {
361
+ inDoubleQuote = !inDoubleQuote;
362
+ } else if (char === '`' && !inSingleQuote && !inDoubleQuote) {
363
+ inBacktick = !inBacktick;
364
+ }
365
+
366
+ if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
367
+ if (char === '(') {
368
+ parenCount += 1;
369
+ } else if (char === ')') {
370
+ parenCount -= 1;
371
+ }
372
+ }
373
+
374
+ i += 1;
375
+ }
376
+ } else {
377
+ result += source[i];
378
+ i += 1;
379
+ }
380
+ }
381
+
382
+ return result;
383
+ }
384
+
385
+ export function extractStaticFieldReferenceMatches(code) {
386
+ const cleanedCode = removeEvalContents(code);
387
+ const fieldRegex = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
388
+ const matches = [];
389
+ let match;
390
+
391
+ while ((match = fieldRegex.exec(cleanedCode)) !== null) {
392
+ matches.push({
393
+ fieldName: match[1],
394
+ reference: `$${match[1]}`,
395
+ index: match.index,
396
+ length: match[0].length,
397
+ });
398
+ }
399
+
400
+ return matches;
401
+ }
402
+
403
+ export function extractStaticFieldReferences(code) {
404
+ const fieldReferences = new Set(
405
+ extractStaticFieldReferenceMatches(code).map((match) => match.fieldName)
406
+ );
407
+
408
+ extractStaticEvalFieldReferences(code).forEach((fieldName) => {
409
+ fieldReferences.add(fieldName);
410
+ });
411
+
412
+ return fieldReferences;
413
+ }
414
+
415
+ export function buildCalculationExecutionContext(form, fieldDataName, resolver) {
416
+ return {
417
+ type: 'calculation',
418
+ fieldName: fieldDataName,
419
+ parentPath: resolver.getFieldInfo(fieldDataName)?.parentPath || [],
420
+ };
421
+ }
422
+
423
+ export function buildCalculationDependencyPlan(schema, contextResolver = null) {
424
+ const form = normalizeCalculationFormSchema(schema);
425
+ const resolver = contextResolver || new ContextResolver(form);
426
+ const fields = flattenFields(form.elements).filter((field) => field && typeof field === 'object');
427
+ const fieldsByDataName = new Map(
428
+ fields
429
+ .filter((field) => typeof field.data_name === 'string' && field.data_name.length > 0)
430
+ .map((field) => [field.data_name, field])
431
+ );
432
+
433
+ const orderByFieldName = new Map();
434
+ fields.forEach((field, index) => {
435
+ if (typeof field?.data_name === 'string') {
436
+ orderByFieldName.set(field.data_name, index);
437
+ }
438
+ });
439
+
440
+ const nodesByDataName = new Map();
441
+ fields.forEach((field) => {
442
+ if (field.type !== 'CalculatedField' || typeof field.data_name !== 'string') {
443
+ return;
444
+ }
445
+
446
+ const fieldName = field.data_name;
447
+ const executionContext = buildCalculationExecutionContext(form, fieldName, resolver);
448
+ const directReferences = extractStaticFieldReferences(field.calculate || '');
449
+ const dependencies = new Set();
450
+
451
+ directReferences.forEach((referenceFieldName) => {
452
+ const accessInfo = resolver.resolveFieldAccessInfo(executionContext, referenceFieldName);
453
+ if (accessInfo.level !== 'accessible') {
454
+ return;
455
+ }
456
+
457
+ const dependencyField = fieldsByDataName.get(referenceFieldName);
458
+ if (dependencyField?.type === 'CalculatedField') {
459
+ dependencies.add(referenceFieldName);
460
+ }
461
+ });
462
+
463
+ nodesByDataName.set(fieldName, {
464
+ field,
465
+ fieldName,
466
+ dependencies,
467
+ directReferences,
468
+ hasDynamicDependencies: hasDynamicCalculationDependencies(field.calculate || ''),
469
+ });
470
+ });
471
+
472
+ const componentFieldGroups = buildStronglyConnectedComponents(nodesByDataName, orderByFieldName);
473
+ const componentIdByFieldName = new Map();
474
+ const componentsById = new Map();
475
+
476
+ componentFieldGroups.forEach((fieldNames, componentIndex) => {
477
+ const componentId = `component_${componentIndex}`;
478
+ fieldNames.forEach((fieldName) => {
479
+ componentIdByFieldName.set(fieldName, componentId);
480
+ });
481
+ componentsById.set(componentId, {
482
+ id: componentId,
483
+ fieldNames,
484
+ hasDynamicDependencies: false,
485
+ isCyclic: false,
486
+ });
487
+ });
488
+
489
+ nodesByDataName.forEach((node) => {
490
+ node.componentId = componentIdByFieldName.get(node.fieldName);
491
+ });
492
+
493
+ componentsById.forEach((component) => {
494
+ component.hasDynamicDependencies = component.fieldNames.some(
495
+ (fieldName) => nodesByDataName.get(fieldName)?.hasDynamicDependencies
496
+ );
497
+ component.isCyclic =
498
+ component.fieldNames.length > 1 ||
499
+ component.fieldNames.some((fieldName) =>
500
+ nodesByDataName.get(fieldName)?.dependencies.has(fieldName)
501
+ );
502
+ });
503
+
504
+ const componentEdges = new Map(
505
+ Array.from(componentsById.keys(), (componentId) => [componentId, new Set()])
506
+ );
507
+
508
+ nodesByDataName.forEach((node) => {
509
+ const targetComponentId = node.componentId;
510
+ node.dependencies.forEach((dependencyFieldName) => {
511
+ const dependencyComponentId = componentIdByFieldName.get(dependencyFieldName);
512
+ if (!dependencyComponentId || dependencyComponentId === targetComponentId) {
513
+ return;
514
+ }
515
+ componentEdges.get(dependencyComponentId).add(targetComponentId);
516
+ });
517
+ });
518
+
519
+ const orderedComponentIds = topologicallyOrderComponents(
520
+ Array.from(componentsById.keys()),
521
+ componentEdges
522
+ );
523
+ const orderedFieldNames = orderedComponentIds.flatMap(
524
+ (componentId) => componentsById.get(componentId)?.fieldNames || []
525
+ );
526
+ const dynamicFieldNames = orderedFieldNames.filter(
527
+ (fieldName) => nodesByDataName.get(fieldName)?.hasDynamicDependencies
528
+ );
529
+ const cyclicComponentIds = orderedComponentIds.filter(
530
+ (componentId) => componentsById.get(componentId)?.isCyclic
531
+ );
532
+
533
+ return {
534
+ form,
535
+ fieldsByDataName,
536
+ nodesByDataName,
537
+ componentsById,
538
+ componentIdByFieldName,
539
+ orderedComponentIds,
540
+ orderedFieldNames,
541
+ hasDynamicDependencies: dynamicFieldNames.length > 0,
542
+ dynamicFieldNames,
543
+ cyclicComponentIds,
544
+ totalCalculatedFieldCount: orderedFieldNames.length,
545
+ };
546
+ }
@@ -8,6 +8,13 @@ import { createFormEngine } from '../engine/form-engine.js';
8
8
  import { WarningSystem } from '../engine/warning-system.js';
9
9
  import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
10
10
  import { validateExpression } from '../security/validation.js';
11
+ import {
12
+ buildCalculationDependencyPlan,
13
+ buildCalculationExecutionContext,
14
+ extractStaticFieldReferenceMatches,
15
+ hasDynamicCalculationDependencies,
16
+ normalizeCalculationFormSchema,
17
+ } from './calculation-dependencies.js';
11
18
  import {
12
19
  isMultilineCalculationExpression,
13
20
  normalizeInlineCalculationExpression,
@@ -53,67 +60,10 @@ const CALCULATION_BUILTIN_METADATA_BY_NAME = new Map(
53
60
  CALCULATION_BUILTIN_CATALOG.map((definition) => [definition.name, definition])
54
61
  );
55
62
 
56
- function normalizeFormSchema(schema) {
57
- if (schema && Array.isArray(schema.elements)) {
58
- return schema;
59
- }
60
-
61
- if (schema && schema.form && Array.isArray(schema.form.elements)) {
62
- return schema.form;
63
- }
64
-
65
- throw new Error('Expected a form schema or a root schema containing form.elements');
66
- }
67
-
68
63
  function isBuiltinCallMatchAllowed(functionName) {
69
64
  return COMMON_GLOBAL_FUNCTIONS.has(functionName);
70
65
  }
71
66
 
72
- function removeEvalContents(code) {
73
- let result = '';
74
- let i = 0;
75
-
76
- while (i < code.length) {
77
- const evalMatch = code.substring(i).match(/^EVAL\s*\(/);
78
- if (evalMatch) {
79
- result += 'EVAL()';
80
- i += evalMatch[0].length;
81
-
82
- let parenCount = 1;
83
- let inSingleQuote = false;
84
- let inDoubleQuote = false;
85
- let inBacktick = false;
86
-
87
- while (i < code.length && parenCount > 0) {
88
- const char = code[i];
89
-
90
- if (char === "'" && !inDoubleQuote && !inBacktick) {
91
- inSingleQuote = !inSingleQuote;
92
- } else if (char === '"' && !inSingleQuote && !inBacktick) {
93
- inDoubleQuote = !inDoubleQuote;
94
- } else if (char === '`' && !inSingleQuote && !inDoubleQuote) {
95
- inBacktick = !inBacktick;
96
- }
97
-
98
- if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
99
- if (char === '(') {
100
- parenCount++;
101
- } else if (char === ')') {
102
- parenCount--;
103
- }
104
- }
105
-
106
- i++;
107
- }
108
- } else {
109
- result += code[i];
110
- i++;
111
- }
112
- }
113
-
114
- return result;
115
- }
116
-
117
67
  function extractFunctionCalls(code) {
118
68
  const functionCallPattern = /\b([A-Z_][A-Z0-9_]*)\s*\(/g;
119
69
  const matches = [];
@@ -130,32 +80,6 @@ function extractFunctionCalls(code) {
130
80
  return matches;
131
81
  }
132
82
 
133
- function extractFieldReferenceMatches(code) {
134
- const cleanedCode = removeEvalContents(code);
135
- const fieldRegex = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
136
- const matches = [];
137
- let match;
138
-
139
- while ((match = fieldRegex.exec(cleanedCode)) !== null) {
140
- matches.push({
141
- fieldName: match[1],
142
- reference: `$${match[1]}`,
143
- index: match.index,
144
- length: match[0].length,
145
- });
146
- }
147
-
148
- return matches;
149
- }
150
-
151
- function buildExecutionContext(form, fieldDataName, resolver) {
152
- return {
153
- type: 'calculation',
154
- fieldName: fieldDataName,
155
- parentPath: resolver.getFieldInfo(fieldDataName)?.parentPath || [],
156
- };
157
- }
158
-
159
83
  function cloneCatalogDefinition(definition) {
160
84
  return {
161
85
  ...definition,
@@ -254,7 +178,7 @@ function cloneSimulationSchema(schema) {
254
178
  }
255
179
 
256
180
  return {
257
- form: deepClone(normalizeFormSchema(schema)),
181
+ form: deepClone(normalizeCalculationFormSchema(schema)),
258
182
  };
259
183
  }
260
184
 
@@ -383,9 +307,9 @@ export function getCalculationReferenceCatalog({
383
307
  fieldDataName,
384
308
  includeRestricted = false,
385
309
  }) {
386
- const form = normalizeFormSchema(schema);
310
+ const form = normalizeCalculationFormSchema(schema);
387
311
  const resolver = new ContextResolver(form);
388
- const executionContext = buildExecutionContext(form, fieldDataName, resolver);
312
+ const executionContext = buildCalculationExecutionContext(form, fieldDataName, resolver);
389
313
 
390
314
  return flattenFields(form.elements)
391
315
  .filter((field) => field && typeof field === 'object')
@@ -431,9 +355,14 @@ export function analyzeCalculationExpression({
431
355
  const setResultCalls = [];
432
356
 
433
357
  const normalizedExpression = typeof expression === 'string' ? expression : '';
434
- const form = normalizeFormSchema(schema);
358
+ const baseForm = normalizeCalculationFormSchema(schema);
359
+ const form = deepClone(baseForm);
360
+ const targetField = findFieldByDataName(form.elements, fieldDataName);
361
+ if (targetField && targetField.type === 'CalculatedField') {
362
+ targetField.calculate = normalizedExpression;
363
+ }
435
364
  const resolver = new ContextResolver(form);
436
- const executionContext = buildExecutionContext(form, fieldDataName, resolver);
365
+ const executionContext = buildCalculationExecutionContext(form, fieldDataName, resolver);
437
366
 
438
367
  if (normalizedExpression.trim().length === 0) {
439
368
  addIssue(
@@ -590,7 +519,7 @@ export function analyzeCalculationExpression({
590
519
  );
591
520
  }
592
521
 
593
- for (const reference of extractFieldReferenceMatches(normalizedExpression)) {
522
+ for (const reference of extractStaticFieldReferenceMatches(normalizedExpression)) {
594
523
  if (!referencedFieldNames.has(reference.fieldName)) {
595
524
  referencedFieldNames.add(reference.fieldName);
596
525
  referencedFields.push(reference.fieldName);
@@ -633,6 +562,39 @@ export function analyzeCalculationExpression({
633
562
  }
634
563
  }
635
564
 
565
+ if (hasDynamicCalculationDependencies(normalizedExpression)) {
566
+ addIssue(
567
+ issues,
568
+ issueKeys,
569
+ createIssue({
570
+ code: 'dynamic_calculation_dependency',
571
+ severity: 'warning',
572
+ message:
573
+ 'This expression uses dynamic field access through EVAL(). Runtime evaluation will fall back to bounded stabilization.',
574
+ })
575
+ );
576
+ }
577
+
578
+ if (targetField?.type === 'CalculatedField') {
579
+ const dependencyPlan = buildCalculationDependencyPlan(form, resolver);
580
+ const targetNode = dependencyPlan.nodesByDataName.get(fieldDataName);
581
+ const targetComponent = targetNode?.componentId
582
+ ? dependencyPlan.componentsById.get(targetNode.componentId)
583
+ : null;
584
+
585
+ if (targetComponent?.isCyclic) {
586
+ addIssue(
587
+ issues,
588
+ issueKeys,
589
+ createIssue({
590
+ code: 'calculated_field_cycle',
591
+ severity: 'error',
592
+ message: `CalculatedField dependency cycle detected: ${targetComponent.fieldNames.join(' -> ')}.`,
593
+ })
594
+ );
595
+ }
596
+ }
597
+
636
598
  return {
637
599
  valid: !issues.some((issue) => issue.severity === 'error'),
638
600
  issues,
@@ -673,23 +635,20 @@ export function createCalculationPreviewSession({
673
635
  security = DEFAULT_SECURITY_CONFIG,
674
636
  }) {
675
637
  const simulationSchema = cloneSimulationSchema(schema);
676
- const form = normalizeFormSchema(simulationSchema);
638
+ const form = normalizeCalculationFormSchema(simulationSchema);
677
639
  const targetField = findFieldByDataName(form.elements, fieldDataName);
678
640
 
679
641
  if (!targetField) {
680
642
  return {
681
643
  run: () =>
682
- createSimulationErrorResult(
683
- `Field '${fieldDataName}' does not exist in the form schema.`
684
- ),
644
+ createSimulationErrorResult(`Field '${fieldDataName}' does not exist in the form schema.`),
685
645
  dispose: () => {},
686
646
  };
687
647
  }
688
648
 
689
649
  if (targetField.type !== 'CalculatedField') {
690
650
  return {
691
- run: () =>
692
- createSimulationErrorResult(`Field '${fieldDataName}' is not a CalculatedField.`),
651
+ run: () => createSimulationErrorResult(`Field '${fieldDataName}' is not a CalculatedField.`),
693
652
  dispose: () => {},
694
653
  };
695
654
  }
@@ -739,9 +698,13 @@ export function createCalculationPreviewSession({
739
698
 
740
699
  const simulatedResult = engineState.values[fieldDataName];
741
700
  const runtimeError =
742
- runtimeDiagnostics.find((diagnostic) => diagnostic.fieldName === fieldDataName)
701
+ runtimeDiagnostics.find(
702
+ (diagnostic) =>
703
+ diagnostic.fieldName === fieldDataName &&
704
+ (diagnostic.severity || 'error') !== 'warning'
705
+ )?.message ||
706
+ runtimeDiagnostics.find((diagnostic) => (diagnostic.severity || 'error') !== 'warning')
743
707
  ?.message ||
744
- runtimeDiagnostics[0]?.message ||
745
708
  null;
746
709
 
747
710
  return {
@@ -0,0 +1,54 @@
1
+ function isPlainObject(value) {
2
+ if (!value || typeof value !== 'object') {
3
+ return false;
4
+ }
5
+
6
+ return Object.prototype.toString.call(value) === '[object Object]';
7
+ }
8
+
9
+ export function areStructuredValuesEqual(left, right) {
10
+ if (Object.is(left, right)) {
11
+ return true;
12
+ }
13
+
14
+ if (left instanceof Date && right instanceof Date) {
15
+ return left.getTime() === right.getTime();
16
+ }
17
+
18
+ if (Array.isArray(left) && Array.isArray(right)) {
19
+ if (left.length !== right.length) {
20
+ return false;
21
+ }
22
+
23
+ for (let index = 0; index < left.length; index += 1) {
24
+ if (!areStructuredValuesEqual(left[index], right[index])) {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ return true;
30
+ }
31
+
32
+ if (isPlainObject(left) && isPlainObject(right)) {
33
+ const leftKeys = Object.keys(left);
34
+ const rightKeys = Object.keys(right);
35
+
36
+ if (leftKeys.length !== rightKeys.length) {
37
+ return false;
38
+ }
39
+
40
+ for (const key of leftKeys) {
41
+ if (!Object.prototype.hasOwnProperty.call(right, key)) {
42
+ return false;
43
+ }
44
+
45
+ if (!areStructuredValuesEqual(left[key], right[key])) {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ return true;
51
+ }
52
+
53
+ return false;
54
+ }