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
package/src/engine/events.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { runExpression } from './evaluator.js';
|
|
2
|
-
import {
|
|
2
|
+
import { eventBuiltins } from '../builtins/registry.js';
|
|
3
3
|
import { ContextResolver } from './context-resolver.js';
|
|
4
4
|
import { WarningSystem } from './warning-system.js';
|
|
5
|
+
import { createEventSourceRegistry } from './event-source.js';
|
|
6
|
+
import {
|
|
7
|
+
bindEventBuiltins,
|
|
8
|
+
closeEventOperationScope,
|
|
9
|
+
createEventOperationScope,
|
|
10
|
+
runInEventOperationScope,
|
|
11
|
+
} from './event-operation-scope.js';
|
|
5
12
|
|
|
6
13
|
// Global registry to track logged event handlers (development only)
|
|
7
14
|
const _loggedHandlers = new Set();
|
|
@@ -19,6 +26,7 @@ export class EventManager {
|
|
|
19
26
|
this.contextResolver = contextResolver || (schema ? new ContextResolver(schema) : null);
|
|
20
27
|
this.warningSystem = warningSystem || new WarningSystem();
|
|
21
28
|
this.schema = schema;
|
|
29
|
+
this.callbackSources = createEventSourceRegistry();
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
/**
|
|
@@ -29,7 +37,7 @@ export class EventManager {
|
|
|
29
37
|
initializeEventCode(eventCode, context) {
|
|
30
38
|
this.eventContext = context;
|
|
31
39
|
try {
|
|
32
|
-
// Execute
|
|
40
|
+
// Execute initialization once; retain authored callback source for scoped dispatch.
|
|
33
41
|
this.executeEventCode(eventCode, context);
|
|
34
42
|
} catch (error) {
|
|
35
43
|
console.warn('[form0] Event code initialization failed:', error.message);
|
|
@@ -42,13 +50,79 @@ export class EventManager {
|
|
|
42
50
|
* @param {Object} context - The execution context
|
|
43
51
|
*/
|
|
44
52
|
executeEventCode(code, context) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
const executionContext = {
|
|
54
|
+
type: 'event',
|
|
55
|
+
eventType: 'initialization',
|
|
56
|
+
fieldName: null,
|
|
57
|
+
};
|
|
58
|
+
const scope = this.createOperationScope(executionContext, 'form.events.code');
|
|
59
|
+
const scopedContext = bindEventBuiltins(context, eventBuiltins, scope);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// Execute the code to register event listeners (same security as calculated fields).
|
|
63
|
+
const prepared = this.callbackSources.prepare(code, scopedContext);
|
|
64
|
+
const result = runInEventOperationScope(scope, () =>
|
|
65
|
+
runExpression(prepared.code, prepared.context, this.securityConfig, true, this.schema, {
|
|
66
|
+
sourceExpression: code,
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
this.reportAsyncHandlerResult(result, executionContext, 'form.events.code');
|
|
70
|
+
} finally {
|
|
71
|
+
this.processEventOperations(closeEventOperationScope(scope));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
createOperationScope(executionContext, handlerName) {
|
|
76
|
+
return createEventOperationScope((operationName) => {
|
|
77
|
+
this.emitHandlerWarning(
|
|
78
|
+
'late_event_operation_discarded',
|
|
79
|
+
executionContext,
|
|
80
|
+
handlerName,
|
|
81
|
+
operationName
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
emitHandlerWarning(reason, executionContext, handlerName, operationName = null) {
|
|
87
|
+
const eventName = executionContext.eventType;
|
|
88
|
+
const messages = {
|
|
89
|
+
async_handler_unsupported: `Event handler '${handlerName}' for event '${eventName}' returned a Promise or thenable.`,
|
|
90
|
+
late_event_operation_discarded: `Event handler '${handlerName}' for event '${eventName}' attempted ${operationName || 'an event operation'} after its synchronous scope closed. The operation was discarded.`,
|
|
91
|
+
async_handler_rejected: `Event handler '${handlerName}' for event '${eventName}' rejected after its synchronous scope closed.`,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
this.warningSystem.emitWarning({
|
|
95
|
+
type: 'EVENT_HANDLER_WARNING',
|
|
96
|
+
reason,
|
|
97
|
+
severity: reason === 'async_handler_rejected' ? 'error' : 'warning',
|
|
98
|
+
message: messages[reason],
|
|
99
|
+
suggestion:
|
|
100
|
+
'Keep event handlers synchronous and create every event operation before the handler returns.',
|
|
101
|
+
executionContext: { ...executionContext },
|
|
102
|
+
fieldContext: {
|
|
103
|
+
handlerName,
|
|
104
|
+
...(operationName ? { operationName } : {}),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
reportAsyncHandlerResult(result, executionContext, handlerName) {
|
|
110
|
+
if ((typeof result !== 'object' || result === null) && typeof result !== 'function') return;
|
|
111
|
+
|
|
112
|
+
let then;
|
|
113
|
+
try {
|
|
114
|
+
then = result.then;
|
|
115
|
+
} catch {
|
|
116
|
+
this.emitHandlerWarning('async_handler_rejected', executionContext, handlerName);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
48
119
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.
|
|
120
|
+
if (typeof then !== 'function') return;
|
|
121
|
+
|
|
122
|
+
this.emitHandlerWarning('async_handler_unsupported', executionContext, handlerName);
|
|
123
|
+
Promise.resolve(result).catch(() => {
|
|
124
|
+
this.emitHandlerWarning('async_handler_rejected', executionContext, handlerName);
|
|
125
|
+
});
|
|
52
126
|
}
|
|
53
127
|
|
|
54
128
|
/**
|
|
@@ -175,7 +249,7 @@ export class EventManager {
|
|
|
175
249
|
* @param {Object} event - The event object
|
|
176
250
|
* @returns {*} The result of the event handler
|
|
177
251
|
*/
|
|
178
|
-
executeHandlerWithContext(callback, event) {
|
|
252
|
+
executeHandlerWithContext(callback, event, scope) {
|
|
179
253
|
// Create execution context for this event
|
|
180
254
|
const executionContext = {
|
|
181
255
|
type: 'event',
|
|
@@ -184,7 +258,7 @@ export class EventManager {
|
|
|
184
258
|
};
|
|
185
259
|
|
|
186
260
|
// Get the callback code for field reference analysis
|
|
187
|
-
const callbackCode =
|
|
261
|
+
const callbackCode = this.callbackSources.getSource(callback);
|
|
188
262
|
|
|
189
263
|
// Build scoped context if context resolver is available
|
|
190
264
|
let contextWithEvent;
|
|
@@ -203,10 +277,16 @@ export class EventManager {
|
|
|
203
277
|
};
|
|
204
278
|
}
|
|
205
279
|
|
|
280
|
+
contextWithEvent = bindEventBuiltins(contextWithEvent, eventBuiltins, scope);
|
|
281
|
+
|
|
206
282
|
// Execute the callback function with the scoped context
|
|
207
283
|
// This ensures EVAL() and other builtins have access to field values
|
|
208
284
|
const functionCall = `(${callbackCode})(event)`;
|
|
209
|
-
return
|
|
285
|
+
return runInEventOperationScope(scope, () =>
|
|
286
|
+
runExpression(functionCall, contextWithEvent, this.securityConfig, true, this.schema, {
|
|
287
|
+
evaluateAsExpression: true,
|
|
288
|
+
})
|
|
289
|
+
);
|
|
210
290
|
}
|
|
211
291
|
|
|
212
292
|
/**
|
|
@@ -408,31 +488,39 @@ export class EventManager {
|
|
|
408
488
|
const wildcardListeners = eventMap.get('*') || [];
|
|
409
489
|
|
|
410
490
|
[...fieldListeners, ...wildcardListeners].forEach((callback) => {
|
|
491
|
+
const handlerName = callback.name || '<anonymous>';
|
|
492
|
+
const scope = this.createOperationScope(executionContext, handlerName);
|
|
493
|
+
let result;
|
|
494
|
+
let collectedOps = [];
|
|
411
495
|
try {
|
|
412
496
|
// Execute the callback with current form context
|
|
413
497
|
// This ensures EVAL() and other builtins have access to field values
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
|
|
498
|
+
result = this.executeHandlerWithContext(callback, event, scope);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
// Simple error message for now
|
|
501
|
+
console.warn(`[form0] Event listener failed: ${error.message}`);
|
|
502
|
+
} finally {
|
|
503
|
+
collectedOps = closeEventOperationScope(scope);
|
|
504
|
+
const runtimeOps = collectedOps.filter(
|
|
505
|
+
(operation) => operation?.type !== 'EVENT_OPERATION'
|
|
506
|
+
);
|
|
418
507
|
|
|
419
508
|
// Validate operations against context restrictions before adding them
|
|
420
|
-
const validOps = this.validateOperations(
|
|
509
|
+
const validOps = this.validateOperations(runtimeOps, executionContext);
|
|
421
510
|
operations.push(...validOps);
|
|
422
511
|
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
|
|
512
|
+
// EVENT_OPERATION processing is initialization-only. ON/OFF calls inside a handler neither
|
|
513
|
+
// mutate the listener registry nor leak internal registration descriptors to the host.
|
|
514
|
+
}
|
|
426
515
|
|
|
516
|
+
try {
|
|
517
|
+
this.reportAsyncHandlerResult(result, executionContext, handlerName);
|
|
427
518
|
// For backward compatibility, still handle returned operations
|
|
428
|
-
if (result && result.type === 'UI_OPERATION') {
|
|
519
|
+
if (result && result.type === 'UI_OPERATION' && !collectedOps.includes(result)) {
|
|
429
520
|
operations.push(result);
|
|
430
521
|
}
|
|
431
522
|
} catch (error) {
|
|
432
|
-
// Simple error message for now
|
|
433
523
|
console.warn(`[form0] Event listener failed: ${error.message}`);
|
|
434
|
-
// Make sure to consume operations even if callback fails
|
|
435
|
-
__consumeEventOperations();
|
|
436
524
|
}
|
|
437
525
|
});
|
|
438
526
|
|
|
@@ -13,6 +13,35 @@ 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
15
|
import { buildCalculationDependencyPlan } from '../utilities/calculation-dependencies.js';
|
|
16
|
+
import { createCalculationDiagnostics } from './calculation-diagnostics.js';
|
|
17
|
+
import { formatLocalDate } from '../utilities/date-utils.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} CalculationDiagnostic
|
|
21
|
+
* @property {string} code Stable diagnostic identifier.
|
|
22
|
+
* @property {'warning'|'error'} severity
|
|
23
|
+
* @property {string} message
|
|
24
|
+
* @property {string} fieldName Calculated field's data_name.
|
|
25
|
+
* @property {'dependency'|'scope'|'validation'|'syntax'|'runtime'} phase
|
|
26
|
+
* @property {string|null} suggestion
|
|
27
|
+
* @property {Object} context JSON-safe metadata; no expression source, values or stack.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a form engine. Calculations collect diagnostics independently of validation.
|
|
32
|
+
* @param {Object} options
|
|
33
|
+
* @param {Object} options.schema
|
|
34
|
+
* @param {Object} [options.initialValues]
|
|
35
|
+
* @param {Object} [options.helpers]
|
|
36
|
+
* @param {Object} [options.security]
|
|
37
|
+
* @param {WarningSystem} [options.warningSystem]
|
|
38
|
+
* @param {Array} [options.runtimeDiagnostics] Deprecated append-only compatibility adapter.
|
|
39
|
+
* Use getDiagnostics() for the latest snapshot or onDiagnostics for evaluation notifications.
|
|
40
|
+
* @param {{console?: boolean}} [options.diagnostics] Omit console to preserve legacy reporting.
|
|
41
|
+
* @param {(snapshot: CalculationDiagnostic[]) => (void|Promise<void>)} [options.onDiagnostics]
|
|
42
|
+
* Called once per completed evaluation, including empty/identical snapshots. Failures are isolated.
|
|
43
|
+
* @returns {Object} Engine with getDiagnostics(): defensive copy of latest evaluation (initially []).
|
|
44
|
+
*/
|
|
16
45
|
|
|
17
46
|
export function createFormEngine({
|
|
18
47
|
schema,
|
|
@@ -21,6 +50,8 @@ export function createFormEngine({
|
|
|
21
50
|
security = DEFAULT_SECURITY_CONFIG,
|
|
22
51
|
warningSystem = null,
|
|
23
52
|
runtimeDiagnostics = null,
|
|
53
|
+
diagnostics = {},
|
|
54
|
+
onDiagnostics = null,
|
|
24
55
|
}) {
|
|
25
56
|
const { schema: preparedSchema, buildingPlanMeta } = expandBuildingPlanSchema(schema);
|
|
26
57
|
|
|
@@ -97,6 +128,12 @@ export function createFormEngine({
|
|
|
97
128
|
const contextResolver = new ContextResolver(form);
|
|
98
129
|
const calculationPlan = buildCalculationDependencyPlan(form, contextResolver);
|
|
99
130
|
const sharedWarningSystem = warningSystem || new WarningSystem();
|
|
131
|
+
const calculationDiagnostics = createCalculationDiagnostics(
|
|
132
|
+
diagnostics,
|
|
133
|
+
onDiagnostics,
|
|
134
|
+
contextResolver,
|
|
135
|
+
sharedWarningSystem
|
|
136
|
+
);
|
|
100
137
|
|
|
101
138
|
// Initialize event system with context resolution
|
|
102
139
|
const eventManager = new EventManager(form, contextResolver, sharedWarningSystem);
|
|
@@ -123,20 +160,28 @@ export function createFormEngine({
|
|
|
123
160
|
}
|
|
124
161
|
|
|
125
162
|
function evalForm() {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
163
|
+
const frame = calculationDiagnostics.begin();
|
|
164
|
+
try {
|
|
165
|
+
evaluateCalculatedFields(
|
|
166
|
+
form,
|
|
167
|
+
values,
|
|
168
|
+
allHelpers,
|
|
169
|
+
security,
|
|
170
|
+
contextResolver,
|
|
171
|
+
sharedWarningSystem,
|
|
172
|
+
runtimeDiagnostics,
|
|
173
|
+
calculationPlan,
|
|
174
|
+
calculationDiagnostics
|
|
175
|
+
);
|
|
176
|
+
evaluateRequirement(form, values, required);
|
|
177
|
+
evaluateVisibility(form, values, visible);
|
|
178
|
+
evaluateReadOnly(form, values, read_only);
|
|
179
|
+
validateFields(form, values, errors);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
calculationDiagnostics.abort(frame);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
calculationDiagnostics.complete(frame);
|
|
140
185
|
}
|
|
141
186
|
|
|
142
187
|
function trigger(eventType, fieldKey, metadata = {}) {
|
|
@@ -158,6 +203,7 @@ export function createFormEngine({
|
|
|
158
203
|
eval: evalForm,
|
|
159
204
|
trigger,
|
|
160
205
|
getState,
|
|
206
|
+
getDiagnostics: calculationDiagnostics.getSnapshot,
|
|
161
207
|
getWarningSystem: () => sharedWarningSystem,
|
|
162
208
|
getContextResolver: () => contextResolver,
|
|
163
209
|
getBuildingPlanMeta: () => buildingPlanMeta,
|
|
@@ -222,8 +268,7 @@ function getDefaultValueLegacy(field) {
|
|
|
222
268
|
|
|
223
269
|
case 'DateField':
|
|
224
270
|
if (field.default_value === 'now') {
|
|
225
|
-
|
|
226
|
-
return today.toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
271
|
+
return formatLocalDate(); // YYYY-MM-DD, local calendar day
|
|
227
272
|
}
|
|
228
273
|
return null;
|
|
229
274
|
|
|
@@ -118,7 +118,7 @@ export class WarningSystem {
|
|
|
118
118
|
* Emit warning to all registered handlers
|
|
119
119
|
* @param {Object} warning - Warning object with structured information
|
|
120
120
|
*/
|
|
121
|
-
emitWarning(warning) {
|
|
121
|
+
emitWarning(warning, options = {}) {
|
|
122
122
|
// Throttle warnings to prevent spam
|
|
123
123
|
if (this.shouldThrottleWarning(warning)) {
|
|
124
124
|
return;
|
|
@@ -136,12 +136,12 @@ export class WarningSystem {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
// Console logging for development
|
|
139
|
-
if (this.enableConsoleWarnings) {
|
|
139
|
+
if (options.console ?? this.enableConsoleWarnings) {
|
|
140
140
|
this.logWarningToConsole(warning);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
// Custom handlers (for "reform" and other integrations)
|
|
144
|
-
this.notifyCustomHandlers(warning);
|
|
144
|
+
this.notifyCustomHandlers(warning, options);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
/**
|
|
@@ -194,12 +194,14 @@ export class WarningSystem {
|
|
|
194
194
|
* Notify all custom warning handlers
|
|
195
195
|
* @param {Object} warning - Warning object
|
|
196
196
|
*/
|
|
197
|
-
notifyCustomHandlers(warning) {
|
|
197
|
+
notifyCustomHandlers(warning, options = {}) {
|
|
198
198
|
for (const handler of this.warningHandlers) {
|
|
199
199
|
try {
|
|
200
200
|
handler(warning);
|
|
201
201
|
} catch (err) {
|
|
202
|
-
console
|
|
202
|
+
if (options.console ?? this.enableConsoleWarnings) {
|
|
203
|
+
console.error('[form0] Warning handler failed:', err);
|
|
204
|
+
}
|
|
203
205
|
// Don't remove the handler automatically - let the user decide
|
|
204
206
|
}
|
|
205
207
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
validateFormLinkRecordConditions,
|
|
7
7
|
} from './form-link-validators.js';
|
|
8
8
|
import { BUILDING_PLAN_BLUEPRINT } from './building-plan-blueprint.js';
|
|
9
|
+
import { formatLocalDate } from '../utilities/date-utils.js';
|
|
9
10
|
const AI_ATTRIBUTE_DEFINITION = {
|
|
10
11
|
type: 'object',
|
|
11
12
|
required: false,
|
|
@@ -725,8 +726,7 @@ export const FIELD_SPECS = {
|
|
|
725
726
|
},
|
|
726
727
|
defaultProducer: (field) => {
|
|
727
728
|
if (field.default_value === 'now') {
|
|
728
|
-
|
|
729
|
-
return today.toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
729
|
+
return formatLocalDate(); // YYYY-MM-DD, local calendar day
|
|
730
730
|
}
|
|
731
731
|
return null;
|
|
732
732
|
},
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CALCULATION_BUILTIN_DEFINITIONS,
|
|
3
|
+
EVENT_BUILTIN_DEFINITIONS,
|
|
4
|
+
} from '../builtins/builtin-definitions.js';
|
|
2
5
|
import { DEFAULT_SECURITY_CONFIG, SAFE_SECURITY_CONFIG, SECURITY_MODES } from './config.js';
|
|
3
6
|
|
|
4
7
|
// Helper function to validate builtin function names in expressions
|
|
5
8
|
function validateBuiltinNames(expr, includeEventBuiltins = false) {
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
: validCalculationBuiltinsSet;
|
|
9
|
+
// Read the same immutable catalog used to build the runtime helpers, without
|
|
10
|
+
// importing their implementations (EVAL itself depends on this validator).
|
|
11
|
+
const definitions = includeEventBuiltins
|
|
12
|
+
? EVENT_BUILTIN_DEFINITIONS
|
|
13
|
+
: CALCULATION_BUILTIN_DEFINITIONS;
|
|
14
|
+
const allowedBuiltins = new Set(definitions.map(({ name }) => name));
|
|
13
15
|
|
|
14
16
|
// Extract function calls from the expression
|
|
15
17
|
// This regex matches function calls like FUNCTIONNAME( allowing for whitespace
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parse } from 'acorn';
|
|
1
2
|
import { BUILTIN_CONTEXTS } from '../builtins/builtin-metadata.js';
|
|
2
3
|
import {
|
|
3
4
|
BUILTIN_DEFINITION_BY_NAME,
|
|
@@ -143,6 +144,38 @@ function isFinalFunctionCall(code, call) {
|
|
|
143
144
|
return index === code.length;
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
function hasTopLevelReturnStatement(code) {
|
|
148
|
+
let program;
|
|
149
|
+
try {
|
|
150
|
+
program = parse(code, {
|
|
151
|
+
ecmaVersion: 'latest',
|
|
152
|
+
allowReturnOutsideFunction: true,
|
|
153
|
+
});
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const visit = (node) => {
|
|
159
|
+
if (!node || typeof node !== 'object') return false;
|
|
160
|
+
if (node.type === 'ReturnStatement') return true;
|
|
161
|
+
if (
|
|
162
|
+
node.type === 'FunctionDeclaration' ||
|
|
163
|
+
node.type === 'FunctionExpression' ||
|
|
164
|
+
node.type === 'ArrowFunctionExpression'
|
|
165
|
+
) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return Object.entries(node).some(([key, value]) => {
|
|
170
|
+
if (key === 'start' || key === 'end' || key === 'loc') return false;
|
|
171
|
+
if (Array.isArray(value)) return value.some(visit);
|
|
172
|
+
return visit(value);
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return program.body.some(visit);
|
|
177
|
+
}
|
|
178
|
+
|
|
146
179
|
function cloneCatalogDefinition(definition) {
|
|
147
180
|
return {
|
|
148
181
|
...definition,
|
|
@@ -582,14 +615,19 @@ export function analyzeCalculationExpression({
|
|
|
582
615
|
);
|
|
583
616
|
}
|
|
584
617
|
|
|
585
|
-
if (
|
|
618
|
+
if (
|
|
619
|
+
isMultilineCalculationExpression(normalizedExpression) &&
|
|
620
|
+
setResultCalls.length === 0 &&
|
|
621
|
+
!hasTopLevelReturnStatement(normalizedExpression)
|
|
622
|
+
) {
|
|
586
623
|
addIssue(
|
|
587
624
|
issues,
|
|
588
625
|
issueKeys,
|
|
589
626
|
createIssue({
|
|
590
627
|
code: 'noncanonical_multiline_result',
|
|
591
628
|
severity: 'warning',
|
|
592
|
-
message:
|
|
629
|
+
message:
|
|
630
|
+
'Multiline calculations should finish with exactly one SETRESULT() call or an explicit return statement.',
|
|
593
631
|
index: 0,
|
|
594
632
|
length: 1,
|
|
595
633
|
})
|
|
@@ -754,13 +792,12 @@ export function createCalculationPreviewSession({
|
|
|
754
792
|
enableConsoleWarnings: false,
|
|
755
793
|
throttleMs: 0,
|
|
756
794
|
});
|
|
757
|
-
const runtimeDiagnostics = [];
|
|
758
795
|
const engine = createFormEngine({
|
|
759
796
|
schema: simulationSchema,
|
|
760
797
|
initialValues: {},
|
|
761
798
|
security,
|
|
762
799
|
warningSystem,
|
|
763
|
-
|
|
800
|
+
diagnostics: { console: false },
|
|
764
801
|
});
|
|
765
802
|
|
|
766
803
|
engine.eval();
|
|
@@ -777,7 +814,6 @@ export function createCalculationPreviewSession({
|
|
|
777
814
|
}
|
|
778
815
|
|
|
779
816
|
warningSystem.clearCollectedWarnings();
|
|
780
|
-
runtimeDiagnostics.length = 0;
|
|
781
817
|
|
|
782
818
|
if (typeof nextExpression === 'string' && nextExpression !== currentExpression) {
|
|
783
819
|
targetField.calculate = nextExpression;
|
|
@@ -790,6 +826,7 @@ export function createCalculationPreviewSession({
|
|
|
790
826
|
try {
|
|
791
827
|
engine.eval();
|
|
792
828
|
|
|
829
|
+
const runtimeDiagnostics = engine.getDiagnostics();
|
|
793
830
|
const simulatedResult = engineState.values[fieldDataName];
|
|
794
831
|
const runtimeError =
|
|
795
832
|
runtimeDiagnostics.find(
|
|
@@ -818,7 +855,6 @@ export function createCalculationPreviewSession({
|
|
|
818
855
|
dispose: () => {
|
|
819
856
|
disposed = true;
|
|
820
857
|
warningSystem.clearCollectedWarnings();
|
|
821
|
-
runtimeDiagnostics.length = 0;
|
|
822
858
|
clearRecord(engineState.values);
|
|
823
859
|
},
|
|
824
860
|
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Calendar-date helpers for DateField values, which use the YYYY-MM-DD format.
|
|
2
|
+
// Dates are handled as UTC calendar days so that arithmetic never shifts across DST changes.
|
|
3
|
+
|
|
4
|
+
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
5
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
6
|
+
const MIN_CALENDAR_YEAR = 1;
|
|
7
|
+
const MAX_CALENDAR_YEAR = 9999;
|
|
8
|
+
|
|
9
|
+
const pad = (value, length = 2) => String(value).padStart(length, '0');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format a Date as YYYY-MM-DD using the local calendar day.
|
|
13
|
+
*
|
|
14
|
+
* @param {Date} [date]
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function formatLocalDate(date = new Date()) {
|
|
18
|
+
return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse a YYYY-MM-DD string into a day number (days since 1970-01-01).
|
|
23
|
+
*
|
|
24
|
+
* @param {string} value
|
|
25
|
+
* @returns {number | null} null when the value is not a valid calendar date
|
|
26
|
+
*/
|
|
27
|
+
export function parseIsoDate(value) {
|
|
28
|
+
if (typeof value !== 'string') return null;
|
|
29
|
+
const match = ISO_DATE_PATTERN.exec(value);
|
|
30
|
+
if (!match) return null;
|
|
31
|
+
|
|
32
|
+
const [year, month, day] = match.slice(1).map(Number);
|
|
33
|
+
if (year < MIN_CALENDAR_YEAR || year > MAX_CALENDAR_YEAR) return null;
|
|
34
|
+
|
|
35
|
+
const date = new Date(0);
|
|
36
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
37
|
+
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1) return null;
|
|
38
|
+
if (date.getUTCDate() !== day) return null;
|
|
39
|
+
|
|
40
|
+
return date.getTime() / MS_PER_DAY;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Format a day number (days since 1970-01-01) as YYYY-MM-DD.
|
|
45
|
+
*
|
|
46
|
+
* @param {number} dayNumber
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function formatIsoDate(dayNumber) {
|
|
50
|
+
const date = new Date(dayNumber * MS_PER_DAY);
|
|
51
|
+
const year = date.getUTCFullYear();
|
|
52
|
+
if (
|
|
53
|
+
!Number.isInteger(dayNumber) ||
|
|
54
|
+
Number.isNaN(date.getTime()) ||
|
|
55
|
+
year < MIN_CALENDAR_YEAR ||
|
|
56
|
+
year > MAX_CALENDAR_YEAR
|
|
57
|
+
) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'Date arithmetic result is outside the supported date range 0001-01-01 to 9999-12-31'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return `${pad(year, 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {unknown} value
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
export function isBlankDateArgument(value) {
|
|
71
|
+
return value === null || value === undefined || value === '';
|
|
72
|
+
}
|