form0-core 0.3.0 → 0.4.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.
- package/README.md +63 -0
- package/package.json +5 -2
- package/src/builtins/builtin-definitions.js +320 -0
- package/src/builtins/choice/choicelabel.js +2 -17
- package/src/builtins/choice/choicelabels.js +2 -17
- package/src/builtins/choice/choicevalue.js +2 -20
- package/src/builtins/choice/choicevalues.js +2 -17
- package/src/builtins/choice/hasother.js +2 -20
- package/src/builtins/choice/other.js +2 -20
- package/src/builtins/control/eval.js +78 -31
- package/src/builtins/control/setresult.js +29 -17
- package/src/builtins/date/dateadd.js +24 -0
- package/src/builtins/date/days.js +22 -0
- package/src/builtins/event/control/off.js +3 -27
- package/src/builtins/event/control/on.js +3 -19
- package/src/builtins/event/event-operations-collector.js +33 -9
- package/src/builtins/event/field/setvalue.js +3 -32
- package/src/builtins/event/ui/alert.js +3 -11
- package/src/builtins/logical/and.js +2 -17
- package/src/builtins/logical/array.js +2 -35
- package/src/builtins/logical/count.js +2 -20
- package/src/builtins/logical/counta.js +2 -20
- package/src/builtins/logical/countblank.js +2 -20
- package/src/builtins/logical/if.js +2 -20
- package/src/builtins/logical/or.js +2 -17
- package/src/builtins/math/abs.js +2 -20
- package/src/builtins/math/ceiling.js +2 -20
- package/src/builtins/math/cos.js +2 -20
- package/src/builtins/math/round.js +2 -23
- package/src/builtins/math/sin.js +2 -20
- package/src/builtins/registry.js +23 -40
- package/src/builtins/schema/datanames.js +9 -20
- package/src/builtins/schema/form.js +2 -20
- package/src/builtins/string/upper.js +2 -20
- package/src/engine/calculation-diagnostics.js +107 -0
- package/src/engine/calculation.js +65 -32
- package/src/engine/conditions.js +69 -3
- package/src/engine/evaluator.js +56 -18
- package/src/engine/event-operation-scope.js +61 -0
- package/src/engine/event-source.js +164 -0
- package/src/engine/events.js +111 -23
- package/src/engine/form-engine.js +61 -16
- package/src/engine/warning-system.js +7 -5
- package/src/schema/field-specs.js +2 -2
- package/src/security/validation.js +10 -8
- package/src/utilities/calculation-editor.js +42 -6
- package/src/utilities/date-utils.js +72 -0
- package/src/builtins/docs/choice/choicelabel.mdx +0 -110
- package/src/builtins/docs/choice/choicelabels.mdx +0 -230
- package/src/builtins/docs/choice/choicevalue.mdx +0 -109
- package/src/builtins/docs/choice/choicevalues.mdx +0 -196
- package/src/builtins/docs/choice/hasother.mdx +0 -133
- package/src/builtins/docs/choice/other.mdx +0 -150
- package/src/builtins/docs/control/setresult.mdx +0 -203
- package/src/builtins/docs/event/field/setvalue.mdx +0 -239
- package/src/builtins/docs/index.mdx +0 -234
- package/src/builtins/docs/logical/and.mdx +0 -141
- package/src/builtins/docs/logical/if.mdx +0 -94
- package/src/builtins/docs/logical/or.mdx +0 -191
- package/src/builtins/docs/overview.mdx +0 -255
|
@@ -16,13 +16,14 @@ export function evaluateCalculatedFields(
|
|
|
16
16
|
contextResolver = null,
|
|
17
17
|
warningSystem = null,
|
|
18
18
|
runtimeDiagnostics = null,
|
|
19
|
-
calculationPlan = null
|
|
19
|
+
calculationPlan = null,
|
|
20
|
+
diagnostics = null
|
|
20
21
|
) {
|
|
21
22
|
const resolver = contextResolver || new ContextResolver(schema);
|
|
22
23
|
const warnings = warningSystem || new WarningSystem();
|
|
23
24
|
const plan = calculationPlan || buildCalculationDependencyPlan(schema, resolver);
|
|
24
25
|
|
|
25
|
-
emitDependencyPlanWarnings(plan, warnings, runtimeDiagnostics);
|
|
26
|
+
emitDependencyPlanWarnings(plan, warnings, runtimeDiagnostics, diagnostics);
|
|
26
27
|
|
|
27
28
|
if (plan.totalCalculatedFieldCount === 0) {
|
|
28
29
|
return;
|
|
@@ -47,7 +48,8 @@ export function evaluateCalculatedFields(
|
|
|
47
48
|
securityConfig,
|
|
48
49
|
resolver,
|
|
49
50
|
warnings,
|
|
50
|
-
runtimeDiagnostics
|
|
51
|
+
runtimeDiagnostics,
|
|
52
|
+
diagnostics
|
|
51
53
|
);
|
|
52
54
|
return;
|
|
53
55
|
}
|
|
@@ -73,7 +75,8 @@ export function evaluateCalculatedFields(
|
|
|
73
75
|
securityConfig,
|
|
74
76
|
resolver,
|
|
75
77
|
warnings,
|
|
76
|
-
runtimeDiagnostics
|
|
78
|
+
runtimeDiagnostics,
|
|
79
|
+
diagnostics
|
|
77
80
|
);
|
|
78
81
|
}
|
|
79
82
|
});
|
|
@@ -103,7 +106,8 @@ function buildScopedContext(
|
|
|
103
106
|
executionContext,
|
|
104
107
|
contextResolver,
|
|
105
108
|
warningSystem,
|
|
106
|
-
expressionCode
|
|
109
|
+
expressionCode,
|
|
110
|
+
diagnostics
|
|
107
111
|
) {
|
|
108
112
|
const ctx = { ...helpers };
|
|
109
113
|
|
|
@@ -139,12 +143,14 @@ function buildScopedContext(
|
|
|
139
143
|
fieldName,
|
|
140
144
|
'restricted'
|
|
141
145
|
);
|
|
142
|
-
|
|
146
|
+
if (diagnostics) diagnostics.emitWarning(warning);
|
|
147
|
+
else warningSystem.emitWarning(warning);
|
|
143
148
|
});
|
|
144
149
|
|
|
145
150
|
notFoundAccessedFields.forEach((fieldName) => {
|
|
146
151
|
const warning = contextResolver.generateAccessWarning(executionContext, fieldName, 'not_found');
|
|
147
|
-
|
|
152
|
+
if (diagnostics) diagnostics.emitWarning(warning);
|
|
153
|
+
else warningSystem.emitWarning(warning);
|
|
148
154
|
});
|
|
149
155
|
|
|
150
156
|
return ctx;
|
|
@@ -181,10 +187,12 @@ function pushRuntimeDiagnostic(
|
|
|
181
187
|
});
|
|
182
188
|
}
|
|
183
189
|
|
|
184
|
-
function emitDependencyPlanWarnings(plan, warningSystem, runtimeDiagnostics) {
|
|
190
|
+
function emitDependencyPlanWarnings(plan, warningSystem, runtimeDiagnostics, diagnostics) {
|
|
191
|
+
const emit = (warning, fieldNames) =>
|
|
192
|
+
diagnostics ? diagnostics.emitWarning(warning, fieldNames) : warningSystem.emitWarning(warning);
|
|
185
193
|
plan.dynamicFieldNames.forEach((fieldName) => {
|
|
186
194
|
const message = `CalculatedField '${fieldName}' uses dynamic field access. Runtime evaluation falls back to bounded stabilization.`;
|
|
187
|
-
|
|
195
|
+
emit({
|
|
188
196
|
type: 'calculation_dependency',
|
|
189
197
|
reason: 'dynamic_dependencies',
|
|
190
198
|
message,
|
|
@@ -215,22 +223,25 @@ function emitDependencyPlanWarnings(plan, warningSystem, runtimeDiagnostics) {
|
|
|
215
223
|
|
|
216
224
|
const fieldNames = component.fieldNames;
|
|
217
225
|
const message = `CalculatedField dependency cycle detected: ${fieldNames.join(' -> ')}. Runtime evaluation is disabled for the involved fields until the cycle is removed.`;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
emit(
|
|
227
|
+
{
|
|
228
|
+
type: 'calculation_dependency',
|
|
229
|
+
reason: 'cyclic_dependencies',
|
|
230
|
+
severity: 'error',
|
|
231
|
+
message,
|
|
232
|
+
suggestion:
|
|
233
|
+
'Remove the cycle or break it with a source field so calculated values can be evaluated deterministically.',
|
|
234
|
+
executionContext: {
|
|
235
|
+
type: 'calculation',
|
|
236
|
+
fieldName: fieldNames[0] || null,
|
|
237
|
+
},
|
|
238
|
+
fieldContext: {
|
|
239
|
+
fieldNames: [...fieldNames],
|
|
240
|
+
cyclic: true,
|
|
241
|
+
},
|
|
232
242
|
},
|
|
233
|
-
|
|
243
|
+
fieldNames
|
|
244
|
+
);
|
|
234
245
|
|
|
235
246
|
fieldNames.forEach((fieldName) => {
|
|
236
247
|
pushRuntimeDiagnostic(
|
|
@@ -252,7 +263,8 @@ function evaluateCalculatedField(
|
|
|
252
263
|
securityConfig,
|
|
253
264
|
resolver,
|
|
254
265
|
warnings,
|
|
255
|
-
runtimeDiagnostics
|
|
266
|
+
runtimeDiagnostics,
|
|
267
|
+
diagnostics
|
|
256
268
|
) {
|
|
257
269
|
if (!field?.data_name || !field.calculate) {
|
|
258
270
|
return { changed: false, hasError: false };
|
|
@@ -266,11 +278,20 @@ function evaluateCalculatedField(
|
|
|
266
278
|
executionContext,
|
|
267
279
|
resolver,
|
|
268
280
|
warnings,
|
|
269
|
-
field.calculate
|
|
281
|
+
field.calculate,
|
|
282
|
+
diagnostics
|
|
270
283
|
);
|
|
271
284
|
const previousValue = values[field.data_name];
|
|
272
285
|
const nextValue = runExpression(field.calculate, context, securityConfig, false, schema, {
|
|
273
|
-
|
|
286
|
+
requireResult: true,
|
|
287
|
+
fieldName: field.data_name,
|
|
288
|
+
suppressConsoleWarning:
|
|
289
|
+
diagnostics?.consoleOverride !== undefined || Array.isArray(runtimeDiagnostics),
|
|
290
|
+
onDiagnostic: (diagnostic) => diagnostics?.record(field.data_name, diagnostic),
|
|
291
|
+
evalReporting: {
|
|
292
|
+
suppressConsole: diagnostics?.consoleOverride !== undefined,
|
|
293
|
+
onDiagnostic: (diagnostic) => diagnostics?.record(field.data_name, diagnostic),
|
|
294
|
+
},
|
|
274
295
|
onError: (error) => {
|
|
275
296
|
pushRuntimeDiagnostic(
|
|
276
297
|
runtimeDiagnostics,
|
|
@@ -288,13 +309,21 @@ function evaluateCalculatedField(
|
|
|
288
309
|
hasError: false,
|
|
289
310
|
};
|
|
290
311
|
} catch (error) {
|
|
312
|
+
diagnostics?.record(field.data_name, {
|
|
313
|
+
code: 'runtime_exception',
|
|
314
|
+
phase: 'runtime',
|
|
315
|
+
message:
|
|
316
|
+
error instanceof Error && error.message
|
|
317
|
+
? error.message
|
|
318
|
+
: 'Unknown calculation runtime error.',
|
|
319
|
+
});
|
|
291
320
|
pushRuntimeDiagnostic(
|
|
292
321
|
runtimeDiagnostics,
|
|
293
322
|
field.data_name,
|
|
294
323
|
error instanceof Error && error.message ? error.message : 'Unknown calculation runtime error.'
|
|
295
324
|
);
|
|
296
325
|
|
|
297
|
-
if (!Array.isArray(runtimeDiagnostics)) {
|
|
326
|
+
if (diagnostics?.consoleOverride === undefined && !Array.isArray(runtimeDiagnostics)) {
|
|
298
327
|
console.warn(`Calculation failed for ${field.data_name}:`, error?.message);
|
|
299
328
|
}
|
|
300
329
|
|
|
@@ -311,7 +340,8 @@ function evaluateFieldSequenceUntilStable(
|
|
|
311
340
|
securityConfig,
|
|
312
341
|
resolver,
|
|
313
342
|
warnings,
|
|
314
|
-
runtimeDiagnostics
|
|
343
|
+
runtimeDiagnostics,
|
|
344
|
+
diagnostics
|
|
315
345
|
) {
|
|
316
346
|
const maxPasses = Math.max(2, fieldNames.length);
|
|
317
347
|
let stillChangingFieldNames = [];
|
|
@@ -333,7 +363,8 @@ function evaluateFieldSequenceUntilStable(
|
|
|
333
363
|
securityConfig,
|
|
334
364
|
resolver,
|
|
335
365
|
warnings,
|
|
336
|
-
runtimeDiagnostics
|
|
366
|
+
runtimeDiagnostics,
|
|
367
|
+
diagnostics
|
|
337
368
|
);
|
|
338
369
|
|
|
339
370
|
if (result.changed) {
|
|
@@ -347,7 +378,7 @@ function evaluateFieldSequenceUntilStable(
|
|
|
347
378
|
}
|
|
348
379
|
|
|
349
380
|
const message = `Calculated fields did not stabilize within ${maxPasses} evaluation passes: ${stillChangingFieldNames.join(', ')}.`;
|
|
350
|
-
|
|
381
|
+
const warning = {
|
|
351
382
|
type: 'calculation_dependency',
|
|
352
383
|
reason: 'non_converging_runtime',
|
|
353
384
|
message,
|
|
@@ -361,7 +392,9 @@ function evaluateFieldSequenceUntilStable(
|
|
|
361
392
|
fieldNames: [...stillChangingFieldNames],
|
|
362
393
|
maxPasses,
|
|
363
394
|
},
|
|
364
|
-
}
|
|
395
|
+
};
|
|
396
|
+
if (diagnostics) diagnostics.emitWarning(warning, stillChangingFieldNames);
|
|
397
|
+
else warnings.emitWarning(warning);
|
|
365
398
|
|
|
366
399
|
stillChangingFieldNames.forEach((fieldName) => {
|
|
367
400
|
pushRuntimeDiagnostic(
|
package/src/engine/conditions.js
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
import { flattenFields } from '../utilities/field-helpers.js';
|
|
2
2
|
|
|
3
|
+
const valuesEqual = (a, b) => {
|
|
4
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
5
|
+
if (a.length !== b.length) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const unmatched = [...b];
|
|
10
|
+
return a.every((value) => {
|
|
11
|
+
const matchIndex = unmatched.findIndex((candidate) => candidate === value);
|
|
12
|
+
if (matchIndex === -1) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
unmatched.splice(matchIndex, 1);
|
|
16
|
+
return true;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return a === b;
|
|
21
|
+
};
|
|
22
|
+
|
|
3
23
|
const OPERATORS = {
|
|
4
|
-
equal_to:
|
|
5
|
-
not_equal_to: (a, b) => a
|
|
24
|
+
equal_to: valuesEqual,
|
|
25
|
+
not_equal_to: (a, b) => !valuesEqual(a, b),
|
|
6
26
|
greater_than: (a, b) => a > b,
|
|
7
27
|
less_than: (a, b) => a < b,
|
|
8
28
|
greater_or_equal_than: (a, b) => a >= b,
|
|
@@ -15,6 +35,52 @@ const OPERATORS = {
|
|
|
15
35
|
!(a === null || a === undefined || a === '' || (Array.isArray(a) && a.length === 0)),
|
|
16
36
|
};
|
|
17
37
|
|
|
38
|
+
const hasValue = (entry, property) =>
|
|
39
|
+
entry &&
|
|
40
|
+
typeof entry === 'object' &&
|
|
41
|
+
Object.prototype.hasOwnProperty.call(entry, property) &&
|
|
42
|
+
entry[property] !== null &&
|
|
43
|
+
entry[property] !== undefined &&
|
|
44
|
+
entry[property] !== '';
|
|
45
|
+
|
|
46
|
+
const getOtherValue = (entry) => {
|
|
47
|
+
if (hasValue(entry, 'value')) {
|
|
48
|
+
return entry.value;
|
|
49
|
+
}
|
|
50
|
+
if (hasValue(entry, 'label')) {
|
|
51
|
+
return entry.label;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function normalizeConditionValue(field, value) {
|
|
57
|
+
if (!field || !value || typeof value !== 'object' || Array.isArray(value)) {
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (field.type === 'SingleChoiceField' || field.type === 'BooleanField') {
|
|
62
|
+
const selectedChoice = Array.isArray(value.choice) ? value.choice[0] : null;
|
|
63
|
+
if (hasValue(selectedChoice, 'value')) {
|
|
64
|
+
return selectedChoice.value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const otherEntry = Array.isArray(value.other) ? value.other[0] : null;
|
|
68
|
+
return getOtherValue(otherEntry);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (field.type === 'MultiChoiceField') {
|
|
72
|
+
const selectedValues = Array.isArray(value.choices)
|
|
73
|
+
? value.choices.filter((choice) => hasValue(choice, 'value')).map((choice) => choice.value)
|
|
74
|
+
: [];
|
|
75
|
+
const otherValues = Array.isArray(value.other)
|
|
76
|
+
? value.other.map(getOtherValue).filter((otherValue) => otherValue !== null)
|
|
77
|
+
: [];
|
|
78
|
+
return [...selectedValues, ...otherValues];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
18
84
|
export function evaluateConditions(conditions, values, allFields) {
|
|
19
85
|
if (Array.isArray(conditions)) {
|
|
20
86
|
return conditions.every((c) => evaluateConditions(c, values, allFields));
|
|
@@ -31,7 +97,7 @@ export function evaluateConditions(conditions, values, allFields) {
|
|
|
31
97
|
// Map field_id (key) to data_name for value lookup
|
|
32
98
|
const field = allFields ? allFields[conditions.field_id] : undefined;
|
|
33
99
|
const dataName = field ? field.data_name : undefined;
|
|
34
|
-
const val = dataName ? values[dataName] : undefined;
|
|
100
|
+
const val = normalizeConditionValue(field, dataName ? values[dataName] : undefined);
|
|
35
101
|
// Debug log: show what is being checked
|
|
36
102
|
//console.log('[evaluateConditions] field_id:', conditions.field_id, 'data_name:', dataName, 'value in values:', val, 'expected:', conditions.value, 'operator:', conditions.operator);
|
|
37
103
|
const fn = OPERATORS[conditions.operator];
|
package/src/engine/evaluator.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { __setEvalContext
|
|
3
|
-
import { __setDataNamesContext
|
|
1
|
+
import { __beginResultScope, __consumeResult } from '../builtins/registry.js';
|
|
2
|
+
import { __setEvalContext } from '../builtins/control/eval.js';
|
|
3
|
+
import { __setDataNamesContext } from '../builtins/schema/datanames.js';
|
|
4
4
|
import { validateExpression, createSecureContext, withTimeout } from '../security/validation.js';
|
|
5
5
|
import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
|
|
6
6
|
import {
|
|
@@ -8,6 +8,24 @@ import {
|
|
|
8
8
|
normalizeInlineCalculationExpression,
|
|
9
9
|
} from '../utilities/calculation-expression-utils.js';
|
|
10
10
|
|
|
11
|
+
const MISSING_RESULT_MESSAGE =
|
|
12
|
+
'Multiline calculation produced no value because it neither calls SETRESULT() nor returns a value.';
|
|
13
|
+
|
|
14
|
+
function reportMissingResult(options) {
|
|
15
|
+
options.onDiagnostic?.({
|
|
16
|
+
code: 'missing_result',
|
|
17
|
+
severity: 'warning',
|
|
18
|
+
phase: 'runtime',
|
|
19
|
+
message: MISSING_RESULT_MESSAGE,
|
|
20
|
+
suggestion:
|
|
21
|
+
'Wrap the final value in SETRESULT(), use an explicit return statement, or write the calculation on a single line.',
|
|
22
|
+
});
|
|
23
|
+
if (!options.suppressConsoleWarning) {
|
|
24
|
+
const field = options.fieldName ? ` (${options.fieldName})` : '';
|
|
25
|
+
console.warn(`[form0] missing_result${field}: ${MISSING_RESULT_MESSAGE}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
11
29
|
export function runExpression(
|
|
12
30
|
expr,
|
|
13
31
|
context = {},
|
|
@@ -16,13 +34,18 @@ export function runExpression(
|
|
|
16
34
|
schema = null,
|
|
17
35
|
options = {}
|
|
18
36
|
) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
__resetResult();
|
|
37
|
+
const restoreResultScope = __beginResultScope();
|
|
38
|
+
let phase = 'runtime';
|
|
22
39
|
try {
|
|
40
|
+
const sourceExpression = options.sourceExpression ?? expr;
|
|
23
41
|
// Validate expression based on security mode
|
|
24
|
-
const validation = validateExpression(
|
|
42
|
+
const validation = validateExpression(sourceExpression, securityConfig, includeEventBuiltins);
|
|
25
43
|
if (!validation.valid) {
|
|
44
|
+
options.onDiagnostic?.({
|
|
45
|
+
code: 'expression_validation_failed',
|
|
46
|
+
phase: 'validation',
|
|
47
|
+
message: validation.reason,
|
|
48
|
+
});
|
|
26
49
|
if (!options.suppressConsoleWarning) {
|
|
27
50
|
console.warn('[form0] Expression validation failed:', validation.reason);
|
|
28
51
|
}
|
|
@@ -37,37 +60,40 @@ export function runExpression(
|
|
|
37
60
|
// Execute expression
|
|
38
61
|
const executeExpression = () => {
|
|
39
62
|
// Set context for EVAL() before execution
|
|
40
|
-
__setEvalContext(secureContext);
|
|
41
|
-
|
|
42
|
-
__setDataNamesContext(schema);
|
|
43
|
-
}
|
|
63
|
+
const restoreEvalContext = __setEvalContext(secureContext, options.evalReporting);
|
|
64
|
+
const restoreDataNamesContext = schema ? __setDataNamesContext(schema) : null;
|
|
44
65
|
|
|
45
66
|
try {
|
|
46
67
|
// Handle both expressions and multi-line code (Windows-safe)
|
|
47
|
-
const isMultiLine =
|
|
68
|
+
const isMultiLine =
|
|
69
|
+
options.evaluateAsExpression !== true &&
|
|
70
|
+
isMultilineCalculationExpression(sourceExpression);
|
|
48
71
|
|
|
49
72
|
if (isMultiLine) {
|
|
50
73
|
// Execute as code block (recompile each time for now)
|
|
74
|
+
phase = 'syntax';
|
|
51
75
|
const fn = new Function(...keys, expr);
|
|
76
|
+
phase = 'runtime';
|
|
52
77
|
const result = fn(...values);
|
|
53
78
|
// Check for consumed result from SETRESULT() calls in multiline code
|
|
54
79
|
const consumed = __consumeResult();
|
|
80
|
+
if (options.requireResult && !consumed.called && result === undefined) {
|
|
81
|
+
reportMissingResult(options);
|
|
82
|
+
}
|
|
55
83
|
return consumed.called ? consumed.value : result;
|
|
56
84
|
} else {
|
|
57
85
|
// Execute as expression (existing behavior)
|
|
58
86
|
const inlineExpression = normalizeInlineCalculationExpression(expr);
|
|
87
|
+
phase = 'syntax';
|
|
59
88
|
const fn = new Function(...keys, `return (${inlineExpression});`);
|
|
89
|
+
phase = 'runtime';
|
|
60
90
|
const result = fn(...values);
|
|
61
91
|
const consumed = __consumeResult();
|
|
62
92
|
return consumed.called ? consumed.value : result;
|
|
63
93
|
}
|
|
64
94
|
} finally {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
__clearEvalContext();
|
|
68
|
-
if (schema) {
|
|
69
|
-
__clearDataNamesContext();
|
|
70
|
-
}
|
|
95
|
+
restoreDataNamesContext?.();
|
|
96
|
+
restoreEvalContext();
|
|
71
97
|
}
|
|
72
98
|
};
|
|
73
99
|
|
|
@@ -80,6 +106,16 @@ export function runExpression(
|
|
|
80
106
|
|
|
81
107
|
return executeExpression();
|
|
82
108
|
} catch (e) {
|
|
109
|
+
options.onDiagnostic?.({
|
|
110
|
+
code:
|
|
111
|
+
phase === 'syntax'
|
|
112
|
+
? e instanceof SyntaxError
|
|
113
|
+
? 'invalid_syntax'
|
|
114
|
+
: 'compilation_error'
|
|
115
|
+
: 'runtime_exception',
|
|
116
|
+
phase,
|
|
117
|
+
message: e instanceof Error && e.message ? e.message : 'Unknown calculation runtime error.',
|
|
118
|
+
});
|
|
83
119
|
if (typeof options.onError === 'function') {
|
|
84
120
|
options.onError(e);
|
|
85
121
|
}
|
|
@@ -87,5 +123,7 @@ export function runExpression(
|
|
|
87
123
|
console.warn('[form0] Expression evaluation failed:', e.message);
|
|
88
124
|
}
|
|
89
125
|
return null;
|
|
126
|
+
} finally {
|
|
127
|
+
restoreResultScope();
|
|
90
128
|
}
|
|
91
129
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__invokeBoundEventBuiltin,
|
|
3
|
+
__runWithEventOperationScope,
|
|
4
|
+
} from '../builtins/event/event-operations-collector.js';
|
|
5
|
+
|
|
6
|
+
const EVENT_OPERATION_BUILTINS = ['ALERT', 'SETVALUE', 'ON', 'OFF'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create an operation scope owned by one synchronous event execution.
|
|
10
|
+
* @param {Function} onLateOperation - Receives only the discarded operation name
|
|
11
|
+
* @returns {Object} Private scope
|
|
12
|
+
*/
|
|
13
|
+
export function createEventOperationScope(onLateOperation) {
|
|
14
|
+
let open = true;
|
|
15
|
+
let operations = [];
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
collect(operation) {
|
|
19
|
+
if (open) {
|
|
20
|
+
operations.push(operation);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
onLateOperation(operation?.operation || operation?.type || 'unknown');
|
|
25
|
+
},
|
|
26
|
+
close() {
|
|
27
|
+
if (!open) return [];
|
|
28
|
+
open = false;
|
|
29
|
+
const collected = operations;
|
|
30
|
+
operations = [];
|
|
31
|
+
return collected;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Bind canonical event operation builtins to their originating scope.
|
|
38
|
+
* Intentional helper overrides are left untouched.
|
|
39
|
+
*/
|
|
40
|
+
export function bindEventBuiltins(context, canonicalBuiltins, scope) {
|
|
41
|
+
const boundContext = { ...context };
|
|
42
|
+
|
|
43
|
+
for (const name of EVENT_OPERATION_BUILTINS) {
|
|
44
|
+
const builtin = canonicalBuiltins[name];
|
|
45
|
+
if (context[name] === builtin) {
|
|
46
|
+
boundContext[name] = (...args) => __invokeBoundEventBuiltin(scope, builtin, args);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return boundContext;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Run synchronous work while raw canonical builtins target this scope. */
|
|
54
|
+
export function runInEventOperationScope(scope, work) {
|
|
55
|
+
return __runWithEventOperationScope(scope, work);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Close a scope and return its operations in creation order. */
|
|
59
|
+
export function closeEventOperationScope(scope) {
|
|
60
|
+
return scope.close();
|
|
61
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { parse } from 'acorn';
|
|
2
|
+
|
|
3
|
+
/** Retains authored callback source without depending on engine-specific toString output. */
|
|
4
|
+
export function createEventSourceRegistry() {
|
|
5
|
+
const sources = new WeakMap();
|
|
6
|
+
|
|
7
|
+
function prepare(code, context) {
|
|
8
|
+
let tree;
|
|
9
|
+
try {
|
|
10
|
+
// Event scripts are Function bodies, rather than modules or global scripts.
|
|
11
|
+
tree = parse(code, { ecmaVersion: 'latest', sourceType: 'commonjs' });
|
|
12
|
+
} catch {
|
|
13
|
+
// Let the existing evaluator validate and report invalid/unsupported source.
|
|
14
|
+
return { code, context };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const identifiers = new Set();
|
|
18
|
+
function collectIdentifiers(node) {
|
|
19
|
+
if (!node || typeof node !== 'object') return;
|
|
20
|
+
if (node.type === 'Identifier') identifiers.add(node.name);
|
|
21
|
+
for (const value of Object.values(node)) {
|
|
22
|
+
if (Array.isArray(value)) value.forEach(collectIdentifiers);
|
|
23
|
+
else if (value && typeof value === 'object') collectIdentifiers(value);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
collectIdentifiers(tree);
|
|
27
|
+
let captureName = '__form0CaptureEventSource';
|
|
28
|
+
while (identifiers.has(captureName) || Object.hasOwn(context, captureName)) captureName += '_';
|
|
29
|
+
const edits = [];
|
|
30
|
+
const propertyNames = new Map();
|
|
31
|
+
|
|
32
|
+
function inferredName(node, parent) {
|
|
33
|
+
if (node.id) return '';
|
|
34
|
+
if (parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier')
|
|
35
|
+
return parent.id.name;
|
|
36
|
+
if (
|
|
37
|
+
parent?.type === 'AssignmentExpression' &&
|
|
38
|
+
['=', '||=', '&&=', '??='].includes(parent.operator) &&
|
|
39
|
+
parent.left.type === 'Identifier'
|
|
40
|
+
)
|
|
41
|
+
return parent.left.name;
|
|
42
|
+
if (parent?.type === 'AssignmentPattern' && parent.left.type === 'Identifier')
|
|
43
|
+
return parent.left.name;
|
|
44
|
+
if (parent?.type === 'Property' && parent.computed) {
|
|
45
|
+
// Preserve ToPropertyKey's single coercion and the inferred function name.
|
|
46
|
+
const key = parent.key;
|
|
47
|
+
edits.push({ position: key.start, rank: 0, text: `${captureName}.key(` });
|
|
48
|
+
edits.push({ position: key.end, rank: 2, text: `,${node.start})` });
|
|
49
|
+
return { computed: node.start };
|
|
50
|
+
}
|
|
51
|
+
if (
|
|
52
|
+
(parent?.type === 'Property' || parent?.type === 'PropertyDefinition') &&
|
|
53
|
+
(!parent.computed || parent.key.type === 'Literal')
|
|
54
|
+
) {
|
|
55
|
+
if (parent.key.type === 'PrivateIdentifier') return `#${parent.key.name}`;
|
|
56
|
+
return parent.key.type === 'Identifier' ? parent.key.name : String(parent.key.value);
|
|
57
|
+
}
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function visit(node, parent) {
|
|
62
|
+
if (!node || typeof node !== 'object') return;
|
|
63
|
+
if (node.type === 'Program' || node.type === 'BlockStatement') {
|
|
64
|
+
const declarations = node.body.filter(
|
|
65
|
+
(statement) => statement.type === 'FunctionDeclaration'
|
|
66
|
+
);
|
|
67
|
+
if (declarations.length) {
|
|
68
|
+
let position = node.type === 'Program' ? node.start : node.start + 1;
|
|
69
|
+
for (const statement of node.body) {
|
|
70
|
+
if (
|
|
71
|
+
statement.type !== 'ExpressionStatement' ||
|
|
72
|
+
statement.expression.type !== 'Literal' ||
|
|
73
|
+
typeof statement.expression.value !== 'string'
|
|
74
|
+
)
|
|
75
|
+
break;
|
|
76
|
+
position = statement.end;
|
|
77
|
+
}
|
|
78
|
+
const text = declarations
|
|
79
|
+
.map(
|
|
80
|
+
(declaration) =>
|
|
81
|
+
`${captureName}(${declaration.id.name},${JSON.stringify(code.slice(declaration.start, declaration.end))});`
|
|
82
|
+
)
|
|
83
|
+
.join('');
|
|
84
|
+
edits.push({ position, rank: 1, text: `;${text}` });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (
|
|
88
|
+
(node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') &&
|
|
89
|
+
!(parent?.type === 'Property' && (parent.method || parent.kind !== 'init')) &&
|
|
90
|
+
parent?.type !== 'MethodDefinition' &&
|
|
91
|
+
// Dynamic class keys need class-specific name metadata; keep their legacy path.
|
|
92
|
+
!(parent?.type === 'PropertyDefinition' && parent.computed && parent.key.type !== 'Literal')
|
|
93
|
+
) {
|
|
94
|
+
const source = code.slice(node.start, node.end);
|
|
95
|
+
const name = inferredName(node, parent);
|
|
96
|
+
const nameExpression =
|
|
97
|
+
typeof name === 'object'
|
|
98
|
+
? `${captureName}.nameFor(${name.computed})`
|
|
99
|
+
: JSON.stringify(name);
|
|
100
|
+
edits.push({ position: node.start, rank: 0, text: `${captureName}(` });
|
|
101
|
+
edits.push({
|
|
102
|
+
position: node.end,
|
|
103
|
+
rank: 2,
|
|
104
|
+
text: `,${JSON.stringify(source)},${nameExpression})`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
for (const [key, value] of Object.entries(node)) {
|
|
108
|
+
if (key === 'start' || key === 'end') continue;
|
|
109
|
+
if (Array.isArray(value)) value.forEach((child) => visit(child, node));
|
|
110
|
+
else if (value && typeof value === 'object') visit(value, node);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
visit(tree, null);
|
|
114
|
+
|
|
115
|
+
let prepared = code;
|
|
116
|
+
// At shared boundaries apply openers first, then hoisted captures, then closers.
|
|
117
|
+
for (const { position, text } of edits.sort(
|
|
118
|
+
(a, b) => b.position - a.position || a.rank - b.rank
|
|
119
|
+
)) {
|
|
120
|
+
prepared = prepared.slice(0, position) + text + prepared.slice(position);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const capture = (callback, source, name) => {
|
|
124
|
+
sources.set(callback, source);
|
|
125
|
+
if (name && !callback.name)
|
|
126
|
+
Object.defineProperty(callback, 'name', { value: name, configurable: true });
|
|
127
|
+
return callback;
|
|
128
|
+
};
|
|
129
|
+
capture.key = (value, id) => {
|
|
130
|
+
const key = Reflect.ownKeys({ [value]: null })[0];
|
|
131
|
+
propertyNames.set(
|
|
132
|
+
id,
|
|
133
|
+
typeof key === 'symbol'
|
|
134
|
+
? key.description === undefined
|
|
135
|
+
? ''
|
|
136
|
+
: `[${key.description}]`
|
|
137
|
+
: key
|
|
138
|
+
);
|
|
139
|
+
return key;
|
|
140
|
+
};
|
|
141
|
+
capture.nameFor = (id) => propertyNames.get(id);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
code: prepared,
|
|
145
|
+
context: {
|
|
146
|
+
...context,
|
|
147
|
+
[captureName]: capture,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getSource(callback) {
|
|
153
|
+
if (sources.has(callback)) return sources.get(callback);
|
|
154
|
+
const source = callback.toString();
|
|
155
|
+
if (/^function\b[^]*\{\s*\[\s*(?:native\s+code|bytecode)\s*\]\s*\}\s*$/.test(source.trim())) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
'Event callback source is unavailable on this JavaScript engine. Define the callback in form.events.code.'
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return source;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { prepare, getSource };
|
|
164
|
+
}
|