form0-core 0.1.7 → 0.1.9

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.7",
3
+ "version": "0.1.9",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
@@ -8,7 +8,7 @@
8
8
  "main": "src/index.js",
9
9
  "type": "module",
10
10
  "scripts": {
11
- "test": "node tests/form0-core.test.js && node tests/builtin-metadata.test.js && node tests/calculation-editor.test.js && node tests/security-test.js",
11
+ "test": "node tests/form0-core.test.js && node tests/builtin-metadata.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/security-test.js",
12
12
  "postinstall": "node scripts/postinstall.cjs",
13
13
  "format": "prettier --write \"**/*.{js,jsx,json,ts,tsx,md}\"",
14
14
  "format:check": "prettier --check \"**/*.{js,jsx,json,ts,tsx,md}\""
@@ -22,7 +22,7 @@ export const SETRESULT_METADATA = defineBuiltinMetadata({
22
22
  signature: 'SETRESULT(value)',
23
23
  description: 'Set the final return value for multiline calculations.',
24
24
  examples: ['SETRESULT($price * $quantity)'],
25
- contexts: [BUILTIN_CONTEXTS.CALCULATION, BUILTIN_CONTEXTS.EVENT],
25
+ contexts: [BUILTIN_CONTEXTS.CALCULATION],
26
26
  });
27
27
 
28
28
  export const SETRESULT = (value) => {
@@ -1,5 +1,6 @@
1
1
  import { __collectEventOperation } from '../event-operations-collector.js';
2
2
  import { BUILTIN_CONTEXTS, defineBuiltinMetadata } from '../../builtin-metadata.js';
3
+ import { isValidEventType } from '../../../engine/event-registry.js';
3
4
 
4
5
  /**
5
6
  * OFF builtin for form events
@@ -37,6 +38,16 @@ export const OFF_METADATA = defineBuiltinMetadata({
37
38
  });
38
39
 
39
40
  export function OFF(eventType, fieldKeyOrCallback, callback) {
41
+ if (typeof eventType !== 'string' || eventType.trim().length === 0) {
42
+ console.warn('[form0] OFF() requires a non-empty event type string');
43
+ return null;
44
+ }
45
+
46
+ if (!isValidEventType(eventType)) {
47
+ console.warn(`[form0] OFF() received an unknown event type: ${eventType}`);
48
+ return null;
49
+ }
50
+
40
51
  let fieldKey = '*';
41
52
  let actualCallback = undefined;
42
53
 
@@ -1,5 +1,6 @@
1
1
  import { __collectEventOperation } from '../event-operations-collector.js';
2
2
  import { BUILTIN_CONTEXTS, defineBuiltinMetadata } from '../../builtin-metadata.js';
3
+ import { isValidEventType } from '../../../engine/event-registry.js';
3
4
 
4
5
  /**
5
6
  * ON builtin for form events
@@ -29,6 +30,16 @@ export const ON_METADATA = defineBuiltinMetadata({
29
30
  });
30
31
 
31
32
  export function ON(eventType, fieldKeyOrCallback, callback) {
33
+ if (typeof eventType !== 'string' || eventType.trim().length === 0) {
34
+ console.warn('[form0] ON() requires a non-empty event type string');
35
+ return null;
36
+ }
37
+
38
+ if (!isValidEventType(eventType)) {
39
+ console.warn(`[form0] ON() received an unknown event type: ${eventType}`);
40
+ return null;
41
+ }
42
+
32
43
  let fieldKey = '*';
33
44
  let actualCallback = fieldKeyOrCallback;
34
45
 
@@ -38,6 +49,11 @@ export function ON(eventType, fieldKeyOrCallback, callback) {
38
49
  actualCallback = callback;
39
50
  }
40
51
 
52
+ if (typeof actualCallback !== 'function') {
53
+ console.warn('[form0] ON() requires a callback function');
54
+ return null;
55
+ }
56
+
41
57
  // Create operation descriptor
42
58
  const operation = {
43
59
  type: 'EVENT_OPERATION',
@@ -65,7 +65,7 @@ export { ON, ON_METADATA } from './event/control/on.js';
65
65
  export { OFF, OFF_METADATA } from './event/control/off.js';
66
66
  export { __consumeEventOperations } from './event/event-operations-collector.js';
67
67
 
68
- const CALCULATION_AND_EVENT_ENTRIES = Object.freeze([
68
+ const COMMON_RUNTIME_ENTRIES = Object.freeze([
69
69
  { implementation: IF, definition: IF_METADATA },
70
70
  { implementation: AND, definition: AND_METADATA },
71
71
  { implementation: OR, definition: OR_METADATA },
@@ -73,7 +73,6 @@ const CALCULATION_AND_EVENT_ENTRIES = Object.freeze([
73
73
  { implementation: COUNTA, definition: COUNTA_METADATA },
74
74
  { implementation: COUNTBLANK, definition: COUNTBLANK_METADATA },
75
75
  { implementation: ARRAY, definition: ARRAY_METADATA },
76
- { implementation: SETRESULT, definition: SETRESULT_METADATA },
77
76
  { implementation: EVAL, definition: EVAL_METADATA },
78
77
  { implementation: CHOICEVALUE, definition: CHOICEVALUE_METADATA },
79
78
  { implementation: CHOICELABEL, definition: CHOICELABEL_METADATA },
@@ -91,6 +90,10 @@ const CALCULATION_AND_EVENT_ENTRIES = Object.freeze([
91
90
  { implementation: UPPER, definition: UPPER_METADATA },
92
91
  ]);
93
92
 
93
+ const CALCULATION_ONLY_ENTRIES = Object.freeze([
94
+ { implementation: SETRESULT, definition: SETRESULT_METADATA },
95
+ ]);
96
+
94
97
  const EVENT_ONLY_ENTRIES = Object.freeze([
95
98
  { implementation: ALERT, definition: ALERT_METADATA },
96
99
  { implementation: SETVALUE, definition: SETVALUE_METADATA },
@@ -110,12 +113,23 @@ function filterDefinitionsByContext(definitions, context) {
110
113
  return Object.freeze(definitions.filter((definition) => definition.contexts.includes(context)));
111
114
  }
112
115
 
113
- export const builtins = buildBuiltinObject(CALCULATION_AND_EVENT_ENTRIES);
116
+ export const calculationBuiltins = buildBuiltinObject([
117
+ ...COMMON_RUNTIME_ENTRIES,
118
+ ...CALCULATION_ONLY_ENTRIES,
119
+ ]);
120
+
121
+ export const eventBuiltins = buildBuiltinObject([
122
+ ...COMMON_RUNTIME_ENTRIES,
123
+ ...EVENT_ONLY_ENTRIES,
124
+ ]);
114
125
 
115
- export const eventBuiltins = buildBuiltinObject(EVENT_ONLY_ENTRIES);
126
+ // Backward-compatible alias for calculation runtime helpers.
127
+ export const builtins = calculationBuiltins;
116
128
 
117
129
  export const BUILTIN_DEFINITIONS = Object.freeze(
118
- [...CALCULATION_AND_EVENT_ENTRIES, ...EVENT_ONLY_ENTRIES].map(({ definition }) => definition)
130
+ [...COMMON_RUNTIME_ENTRIES, ...CALCULATION_ONLY_ENTRIES, ...EVENT_ONLY_ENTRIES].map(
131
+ ({ definition }) => definition
132
+ )
119
133
  );
120
134
 
121
135
  export const CALCULATION_BUILTIN_DEFINITIONS = filterDefinitionsByContext(
@@ -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,69 +15,68 @@ 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
+ if (plan.hasDynamicDependencies) {
32
+ evaluateFieldSequenceUntilStable(
33
+ plan.orderedFieldNames,
34
+ plan,
35
+ schema,
36
+ values,
37
+ helpers,
38
+ securityConfig,
39
+ resolver,
40
+ warnings,
41
+ runtimeDiagnostics
42
+ );
43
+ return;
44
+ }
45
+
46
+ plan.orderedComponentIds.forEach((componentId) => {
47
+ const component = plan.componentsById.get(componentId);
48
+ if (!component || component.fieldNames.length === 0) {
49
+ return;
50
+ }
51
+
52
+ if (component.isCyclic) {
53
+ evaluateFieldSequenceUntilStable(
54
+ component.fieldNames,
55
+ plan,
56
+ schema,
57
+ values,
58
+ helpers,
59
+ securityConfig,
60
+ resolver,
61
+ warnings,
62
+ runtimeDiagnostics
63
+ );
64
+ return;
65
+ }
66
+
67
+ const fieldName = component.fieldNames[0];
68
+ const node = plan.nodesByDataName.get(fieldName);
69
+ if (node?.field) {
70
+ evaluateCalculatedField(
71
+ node.field,
72
+ schema,
73
+ values,
74
+ helpers,
75
+ securityConfig,
76
+ resolver,
77
+ warnings,
78
+ runtimeDiagnostics
79
+ );
76
80
  }
77
81
  });
78
82
  }
@@ -87,43 +91,32 @@ function buildScopedContext(
87
91
  ) {
88
92
  const ctx = { ...helpers };
89
93
 
90
- // Find which fields are actually referenced in the expression
91
- const referencedFields = extractFieldReferences(expressionCode);
92
-
93
- // Track problematic fields that are actually accessed
94
+ const referencedFields = extractStaticFieldReferences(expressionCode);
94
95
  const restrictedAccessedFields = [];
95
96
  const notFoundAccessedFields = [];
96
97
 
97
- // Add scoped field access
98
98
  for (const [fieldName, value] of Object.entries(values)) {
99
99
  const accessLevel = contextResolver.resolveFieldAccess(executionContext, fieldName);
100
100
 
101
101
  if (accessLevel === 'accessible') {
102
102
  ctx[`$${fieldName}`] = value;
103
103
  } else if (accessLevel === 'restricted') {
104
- // Only add restricted fields to context if they're actually referenced
105
104
  if (referencedFields.has(fieldName)) {
106
105
  ctx[`$${fieldName}`] = undefined;
107
106
  restrictedAccessedFields.push(fieldName);
108
107
  }
109
- // If not referenced, don't add to context at all
110
108
  }
111
- // 'not_found' fields are not added to context at all
112
109
  }
113
110
 
114
- // Check for not_found fields that are actually referenced
115
111
  for (const fieldName of referencedFields) {
116
- // Check if this field was processed above (exists in values)
117
112
  if (!(fieldName in values)) {
118
113
  const accessLevel = contextResolver.resolveFieldAccess(executionContext, fieldName);
119
114
  if (accessLevel === 'not_found') {
120
115
  notFoundAccessedFields.push(fieldName);
121
- // Don't add to context - it will be undefined when accessed
122
116
  }
123
117
  }
124
118
  }
125
119
 
126
- // Emit warnings for restricted fields that are actually accessed
127
120
  restrictedAccessedFields.forEach((fieldName) => {
128
121
  const warning = contextResolver.generateAccessWarning(
129
122
  executionContext,
@@ -133,7 +126,6 @@ function buildScopedContext(
133
126
  warningSystem.emitWarning(warning);
134
127
  });
135
128
 
136
- // Emit warnings for not_found fields that are actually accessed
137
129
  notFoundAccessedFields.forEach((fieldName) => {
138
130
  const warning = contextResolver.generateAccessWarning(executionContext, fieldName, 'not_found');
139
131
  warningSystem.emitWarning(warning);
@@ -142,93 +134,225 @@ function buildScopedContext(
142
134
  return ctx;
143
135
  }
144
136
 
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 $)
137
+ function pushRuntimeDiagnostic(
138
+ runtimeDiagnostics,
139
+ fieldName,
140
+ message,
141
+ severity = 'error',
142
+ dedupeKey = null
143
+ ) {
144
+ if (!Array.isArray(runtimeDiagnostics)) {
145
+ return;
165
146
  }
166
147
 
167
- return fieldReferences;
148
+ if (
149
+ dedupeKey &&
150
+ runtimeDiagnostics.some(
151
+ (diagnostic) =>
152
+ diagnostic?.fieldName === fieldName &&
153
+ diagnostic?.message === message &&
154
+ diagnostic?.dedupeKey === dedupeKey
155
+ )
156
+ ) {
157
+ return;
158
+ }
159
+
160
+ runtimeDiagnostics.push({
161
+ fieldName,
162
+ message,
163
+ severity,
164
+ dedupeKey,
165
+ });
168
166
  }
169
167
 
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++;
168
+ function emitDependencyPlanWarnings(plan, warningSystem, runtimeDiagnostics) {
169
+ plan.dynamicFieldNames.forEach((fieldName) => {
170
+ const message = `CalculatedField '${fieldName}' uses dynamic field access. Runtime evaluation falls back to bounded stabilization.`;
171
+ warningSystem.emitWarning({
172
+ type: 'calculation_dependency',
173
+ reason: 'dynamic_dependencies',
174
+ message,
175
+ suggestion: 'Prefer direct $field references when possible to enable dependency ordering.',
176
+ executionContext: {
177
+ type: 'calculation',
178
+ fieldName,
179
+ },
180
+ fieldContext: {
181
+ fieldName,
182
+ dynamic: true,
183
+ },
184
+ });
185
+ pushRuntimeDiagnostic(
186
+ runtimeDiagnostics,
187
+ fieldName,
188
+ message,
189
+ 'warning',
190
+ `dynamic:${fieldName}`
191
+ );
192
+ });
193
+
194
+ plan.cyclicComponentIds.forEach((componentId) => {
195
+ const component = plan.componentsById.get(componentId);
196
+ if (!component?.isCyclic) {
197
+ return;
221
198
  }
199
+
200
+ const fieldNames = component.fieldNames;
201
+ const message = `CalculatedField dependency cycle detected: ${fieldNames.join(' -> ')}. Runtime evaluation will use bounded stabilization.`;
202
+ warningSystem.emitWarning({
203
+ type: 'calculation_dependency',
204
+ reason: 'cyclic_dependencies',
205
+ message,
206
+ suggestion:
207
+ 'Remove the cycle or break it with a source field so calculated values can be evaluated deterministically.',
208
+ executionContext: {
209
+ type: 'calculation',
210
+ fieldName: fieldNames[0] || null,
211
+ },
212
+ fieldContext: {
213
+ fieldNames: [...fieldNames],
214
+ cyclic: true,
215
+ },
216
+ });
217
+
218
+ fieldNames.forEach((fieldName) => {
219
+ pushRuntimeDiagnostic(
220
+ runtimeDiagnostics,
221
+ fieldName,
222
+ message,
223
+ 'warning',
224
+ `cycle:${componentId}`
225
+ );
226
+ });
227
+ });
228
+ }
229
+
230
+ function evaluateCalculatedField(
231
+ field,
232
+ schema,
233
+ values,
234
+ helpers,
235
+ securityConfig,
236
+ resolver,
237
+ warnings,
238
+ runtimeDiagnostics
239
+ ) {
240
+ if (!field?.data_name || !field.calculate) {
241
+ return { changed: false, hasError: false };
222
242
  }
223
243
 
224
- return result;
244
+ try {
245
+ const executionContext = buildCalculationExecutionContext(schema, field.data_name, resolver);
246
+ const context = buildScopedContext(
247
+ values,
248
+ helpers,
249
+ executionContext,
250
+ resolver,
251
+ warnings,
252
+ field.calculate
253
+ );
254
+ const previousValue = values[field.data_name];
255
+ const nextValue = runExpression(field.calculate, context, securityConfig, false, schema, {
256
+ suppressConsoleWarning: Array.isArray(runtimeDiagnostics),
257
+ onError: (error) => {
258
+ pushRuntimeDiagnostic(
259
+ runtimeDiagnostics,
260
+ field.data_name,
261
+ error instanceof Error && error.message
262
+ ? error.message
263
+ : 'Unknown calculation runtime error.'
264
+ );
265
+ },
266
+ });
267
+ values[field.data_name] = nextValue;
268
+
269
+ return {
270
+ changed: !areStructuredValuesEqual(previousValue, nextValue),
271
+ hasError: false,
272
+ };
273
+ } catch (error) {
274
+ pushRuntimeDiagnostic(
275
+ runtimeDiagnostics,
276
+ field.data_name,
277
+ error instanceof Error && error.message ? error.message : 'Unknown calculation runtime error.'
278
+ );
279
+
280
+ if (!Array.isArray(runtimeDiagnostics)) {
281
+ console.warn(`Calculation failed for ${field.data_name}:`, error?.message);
282
+ }
283
+
284
+ return { changed: false, hasError: true };
285
+ }
225
286
  }
226
287
 
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];
288
+ function evaluateFieldSequenceUntilStable(
289
+ fieldNames,
290
+ plan,
291
+ schema,
292
+ values,
293
+ helpers,
294
+ securityConfig,
295
+ resolver,
296
+ warnings,
297
+ runtimeDiagnostics
298
+ ) {
299
+ const maxPasses = Math.max(2, plan.totalCalculatedFieldCount);
300
+ let stillChangingFieldNames = [];
301
+
302
+ for (let pass = 0; pass < maxPasses; pass += 1) {
303
+ stillChangingFieldNames = [];
304
+
305
+ fieldNames.forEach((fieldName) => {
306
+ const node = plan.nodesByDataName.get(fieldName);
307
+ if (!node?.field) {
308
+ return;
309
+ }
310
+
311
+ const result = evaluateCalculatedField(
312
+ node.field,
313
+ schema,
314
+ values,
315
+ helpers,
316
+ securityConfig,
317
+ resolver,
318
+ warnings,
319
+ runtimeDiagnostics
320
+ );
321
+
322
+ if (result.changed) {
323
+ stillChangingFieldNames.push(fieldName);
324
+ }
325
+ });
326
+
327
+ if (stillChangingFieldNames.length === 0) {
328
+ return;
329
+ }
232
330
  }
233
- return { ...ctx, ...helpers };
331
+
332
+ const message = `Calculated fields did not stabilize within ${maxPasses} evaluation passes: ${stillChangingFieldNames.join(', ')}.`;
333
+ warnings.emitWarning({
334
+ type: 'calculation_dependency',
335
+ reason: 'non_converging_runtime',
336
+ message,
337
+ suggestion:
338
+ 'Review the involved CalculatedField expressions for dependency cycles or dynamic references that never settle.',
339
+ executionContext: {
340
+ type: 'calculation',
341
+ fieldName: stillChangingFieldNames[0] || null,
342
+ },
343
+ fieldContext: {
344
+ fieldNames: [...stillChangingFieldNames],
345
+ maxPasses,
346
+ },
347
+ });
348
+
349
+ stillChangingFieldNames.forEach((fieldName) => {
350
+ pushRuntimeDiagnostic(
351
+ runtimeDiagnostics,
352
+ fieldName,
353
+ message,
354
+ 'warning',
355
+ `non-converging:${fieldNames.join(',')}`
356
+ );
357
+ });
234
358
  }