form0-core 0.1.6 → 0.1.8
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 +2 -2
- package/src/builtins/control/setresult.js +1 -1
- package/src/builtins/event/control/off.js +11 -0
- package/src/builtins/event/control/on.js +16 -0
- package/src/builtins/registry.js +19 -5
- package/src/engine/form-engine.js +3 -3
- package/src/index.js +6 -0
- package/src/security/validation.js +6 -7
- package/src/utilities/calculation-editor.js +153 -48
- package/src/utilities/form-event-editor.js +488 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form0-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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
|
|
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',
|
package/src/builtins/registry.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
126
|
+
// Backward-compatible alias for calculation runtime helpers.
|
|
127
|
+
export const builtins = calculationBuiltins;
|
|
116
128
|
|
|
117
129
|
export const BUILTIN_DEFINITIONS = Object.freeze(
|
|
118
|
-
[...
|
|
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(
|
|
@@ -3,7 +3,7 @@ import { evaluateVisibility } from './conditions.js';
|
|
|
3
3
|
import { evaluateRequirement } from './conditions.js';
|
|
4
4
|
import { evaluateReadOnly } from './conditions.js';
|
|
5
5
|
import { validateFields } from './field-validation.js';
|
|
6
|
-
import {
|
|
6
|
+
import { calculationBuiltins, eventBuiltins } from '../builtins/registry.js';
|
|
7
7
|
import { validateSchema } from '../schema/schema-validator.js';
|
|
8
8
|
import { flattenFields } from '../utilities/field-helpers.js';
|
|
9
9
|
import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
|
|
@@ -90,7 +90,7 @@ export function createFormEngine({
|
|
|
90
90
|
const required = {};
|
|
91
91
|
const read_only = {};
|
|
92
92
|
|
|
93
|
-
const allHelpers = { ...
|
|
93
|
+
const allHelpers = { ...calculationBuiltins, ...helpers };
|
|
94
94
|
|
|
95
95
|
// Initialize context resolution system
|
|
96
96
|
const contextResolver = new ContextResolver(form);
|
|
@@ -99,7 +99,7 @@ export function createFormEngine({
|
|
|
99
99
|
// Initialize event system with context resolution
|
|
100
100
|
const eventManager = new EventManager(form, contextResolver, sharedWarningSystem);
|
|
101
101
|
eventManager.securityConfig = security; // Pass security config
|
|
102
|
-
const eventHelpers = { ...
|
|
102
|
+
const eventHelpers = { ...eventBuiltins, ...helpers };
|
|
103
103
|
|
|
104
104
|
// Initialize event code if present
|
|
105
105
|
if (form.events && form.events.code) {
|
package/src/index.js
CHANGED
|
@@ -35,8 +35,14 @@ export {
|
|
|
35
35
|
getCalculationBuiltinCatalog,
|
|
36
36
|
getCalculationReferenceCatalog,
|
|
37
37
|
analyzeCalculationExpression,
|
|
38
|
+
createCalculationPreviewSession,
|
|
38
39
|
simulateCalculationExpression,
|
|
39
40
|
} from './utilities/calculation-editor.js';
|
|
41
|
+
export {
|
|
42
|
+
getFormEventBuiltinCatalog,
|
|
43
|
+
getFormEventReferenceCatalog,
|
|
44
|
+
analyzeFormEventCode,
|
|
45
|
+
} from './utilities/form-event-editor.js';
|
|
40
46
|
export {
|
|
41
47
|
getFormAIMetadata,
|
|
42
48
|
getFormAIContext,
|
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { calculationBuiltins, eventBuiltins } from '../builtins/registry.js';
|
|
2
2
|
import { DEFAULT_SECURITY_CONFIG, SAFE_SECURITY_CONFIG, SECURITY_MODES } from './config.js';
|
|
3
3
|
|
|
4
4
|
// Helper function to validate builtin function names in expressions
|
|
5
5
|
function validateBuiltinNames(expr, includeEventBuiltins = false) {
|
|
6
6
|
// Always use fresh builtin sets (no caching) to support dynamic builtin registration
|
|
7
|
-
const
|
|
7
|
+
const validCalculationBuiltinsSet = new Set(Object.keys(calculationBuiltins));
|
|
8
8
|
const validEventBuiltinsSet = new Set(Object.keys(eventBuiltins));
|
|
9
9
|
|
|
10
|
-
// Combine regular and event builtins if in event context
|
|
11
10
|
const allowedBuiltins = includeEventBuiltins
|
|
12
|
-
?
|
|
13
|
-
:
|
|
11
|
+
? validEventBuiltinsSet
|
|
12
|
+
: validCalculationBuiltinsSet;
|
|
14
13
|
|
|
15
14
|
// Extract function calls from the expression
|
|
16
15
|
// This regex matches function calls like FUNCTIONNAME( allowing for whitespace
|
|
@@ -46,8 +45,8 @@ function validateBuiltinNames(expr, includeEventBuiltins = false) {
|
|
|
46
45
|
}
|
|
47
46
|
|
|
48
47
|
// Check for potential typos by finding similar builtin names
|
|
49
|
-
const
|
|
50
|
-
const similarBuiltins =
|
|
48
|
+
const validBuiltinNames = [...allowedBuiltins];
|
|
49
|
+
const similarBuiltins = validBuiltinNames.filter((builtin) => {
|
|
51
50
|
// Simple similarity check - same length or very close
|
|
52
51
|
const lengthDiff = Math.abs(builtin.length - functionName.length);
|
|
53
52
|
return lengthDiff <= 2 && builtin.startsWith(functionName.substring(0, 3));
|
|
@@ -296,6 +296,33 @@ function sanitizeCollectedWarnings(warnings) {
|
|
|
296
296
|
}));
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
function clearRecord(record) {
|
|
300
|
+
if (!record || typeof record !== 'object') {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
Object.keys(record).forEach((key) => {
|
|
305
|
+
delete record[key];
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function resetEngineValues(targetValues, sourceValues) {
|
|
310
|
+
clearRecord(targetValues);
|
|
311
|
+
Object.assign(targetValues, deepClone(sourceValues));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function normalizeSimulationValues(values) {
|
|
315
|
+
return values && typeof values === 'object' && !Array.isArray(values) ? values : {};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function createSimulationErrorResult(runtimeError, warnings = []) {
|
|
319
|
+
return {
|
|
320
|
+
result: null,
|
|
321
|
+
runtimeError,
|
|
322
|
+
warnings,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
299
326
|
/**
|
|
300
327
|
* @typedef {'stable' | 'advanced' | 'unavailable'} CalculationBuiltinStatus
|
|
301
328
|
*
|
|
@@ -615,30 +642,34 @@ export function analyzeCalculationExpression({
|
|
|
615
642
|
}
|
|
616
643
|
|
|
617
644
|
/**
|
|
618
|
-
*
|
|
645
|
+
* Create a reusable preview session for CalculatedField authoring.
|
|
619
646
|
*
|
|
620
647
|
* @param {Object} options
|
|
621
648
|
* @param {Object} options.schema
|
|
622
649
|
* @param {string} options.fieldDataName
|
|
623
650
|
* @param {string} options.expression
|
|
624
|
-
* @param {Object} [options.values]
|
|
625
651
|
* @param {Object} [options.security]
|
|
626
652
|
* @returns {{
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
653
|
+
* run: (options?: {
|
|
654
|
+
* expression?: string,
|
|
655
|
+
* values?: Object,
|
|
656
|
+
* }) => {
|
|
657
|
+
* result: unknown,
|
|
658
|
+
* runtimeError: string | null,
|
|
659
|
+
* warnings: Array<{
|
|
660
|
+
* message: string,
|
|
661
|
+
* suggestion: string | null,
|
|
662
|
+
* context: Object | null,
|
|
663
|
+
* fieldContext: Object | null,
|
|
664
|
+
* }>,
|
|
665
|
+
* },
|
|
666
|
+
* dispose: () => void,
|
|
635
667
|
* }}
|
|
636
668
|
*/
|
|
637
|
-
export function
|
|
669
|
+
export function createCalculationPreviewSession({
|
|
638
670
|
schema,
|
|
639
671
|
fieldDataName,
|
|
640
672
|
expression,
|
|
641
|
-
values = {},
|
|
642
673
|
security = DEFAULT_SECURITY_CONFIG,
|
|
643
674
|
}) {
|
|
644
675
|
const simulationSchema = cloneSimulationSchema(schema);
|
|
@@ -647,17 +678,19 @@ export function simulateCalculationExpression({
|
|
|
647
678
|
|
|
648
679
|
if (!targetField) {
|
|
649
680
|
return {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
681
|
+
run: () =>
|
|
682
|
+
createSimulationErrorResult(
|
|
683
|
+
`Field '${fieldDataName}' does not exist in the form schema.`
|
|
684
|
+
),
|
|
685
|
+
dispose: () => {},
|
|
653
686
|
};
|
|
654
687
|
}
|
|
655
688
|
|
|
656
689
|
if (targetField.type !== 'CalculatedField') {
|
|
657
690
|
return {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
691
|
+
run: () =>
|
|
692
|
+
createSimulationErrorResult(`Field '${fieldDataName}' is not a CalculatedField.`),
|
|
693
|
+
dispose: () => {},
|
|
661
694
|
};
|
|
662
695
|
}
|
|
663
696
|
|
|
@@ -666,41 +699,113 @@ export function simulateCalculationExpression({
|
|
|
666
699
|
const warningSystem = new WarningSystem({
|
|
667
700
|
enableCollection: true,
|
|
668
701
|
enableConsoleWarnings: false,
|
|
702
|
+
throttleMs: 0,
|
|
669
703
|
});
|
|
670
704
|
const runtimeDiagnostics = [];
|
|
705
|
+
const engine = createFormEngine({
|
|
706
|
+
schema: simulationSchema,
|
|
707
|
+
initialValues: {},
|
|
708
|
+
security,
|
|
709
|
+
warningSystem,
|
|
710
|
+
runtimeDiagnostics,
|
|
711
|
+
});
|
|
671
712
|
|
|
672
|
-
|
|
673
|
-
const engine = createFormEngine({
|
|
674
|
-
schema: simulationSchema,
|
|
675
|
-
initialValues:
|
|
676
|
-
values && typeof values === 'object' && !Array.isArray(values) ? values : {},
|
|
677
|
-
security,
|
|
678
|
-
warningSystem,
|
|
679
|
-
runtimeDiagnostics,
|
|
680
|
-
});
|
|
713
|
+
engine.eval();
|
|
681
714
|
|
|
682
|
-
|
|
715
|
+
const engineState = engine.getState();
|
|
716
|
+
const baselineValues = deepClone(engineState.values);
|
|
717
|
+
let currentExpression = targetField.calculate;
|
|
718
|
+
let disposed = false;
|
|
683
719
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
null;
|
|
720
|
+
return {
|
|
721
|
+
run: ({ expression: nextExpression, values = {} } = {}) => {
|
|
722
|
+
if (disposed) {
|
|
723
|
+
return createSimulationErrorResult('Calculation preview session has been disposed.');
|
|
724
|
+
}
|
|
690
725
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
726
|
+
warningSystem.clearCollectedWarnings();
|
|
727
|
+
runtimeDiagnostics.length = 0;
|
|
728
|
+
|
|
729
|
+
if (typeof nextExpression === 'string' && nextExpression !== currentExpression) {
|
|
730
|
+
targetField.calculate = nextExpression;
|
|
731
|
+
currentExpression = nextExpression;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
resetEngineValues(engineState.values, baselineValues);
|
|
735
|
+
Object.assign(engineState.values, normalizeSimulationValues(values));
|
|
736
|
+
|
|
737
|
+
try {
|
|
738
|
+
engine.eval();
|
|
739
|
+
|
|
740
|
+
const simulatedResult = engineState.values[fieldDataName];
|
|
741
|
+
const runtimeError =
|
|
742
|
+
runtimeDiagnostics.find((diagnostic) => diagnostic.fieldName === fieldDataName)
|
|
743
|
+
?.message ||
|
|
744
|
+
runtimeDiagnostics[0]?.message ||
|
|
745
|
+
null;
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
result: simulatedResult === undefined ? null : simulatedResult,
|
|
749
|
+
runtimeError,
|
|
750
|
+
warnings: sanitizeCollectedWarnings(warningSystem.getCollectedWarnings()),
|
|
751
|
+
};
|
|
752
|
+
} catch (error) {
|
|
753
|
+
return createSimulationErrorResult(
|
|
754
|
+
error instanceof Error && error.message
|
|
755
|
+
? error.message
|
|
756
|
+
: 'Unknown calculation simulation error.',
|
|
757
|
+
sanitizeCollectedWarnings(warningSystem.getCollectedWarnings())
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
dispose: () => {
|
|
762
|
+
disposed = true;
|
|
763
|
+
warningSystem.clearCollectedWarnings();
|
|
764
|
+
runtimeDiagnostics.length = 0;
|
|
765
|
+
clearRecord(engineState.values);
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Simulate a CalculatedField expression against form0-core's real evaluation engine.
|
|
772
|
+
*
|
|
773
|
+
* @param {Object} options
|
|
774
|
+
* @param {Object} options.schema
|
|
775
|
+
* @param {string} options.fieldDataName
|
|
776
|
+
* @param {string} options.expression
|
|
777
|
+
* @param {Object} [options.values]
|
|
778
|
+
* @param {Object} [options.security]
|
|
779
|
+
* @returns {{
|
|
780
|
+
* result: unknown,
|
|
781
|
+
* runtimeError: string | null,
|
|
782
|
+
* warnings: Array<{
|
|
783
|
+
* message: string,
|
|
784
|
+
* suggestion: string | null,
|
|
785
|
+
* context: Object | null,
|
|
786
|
+
* fieldContext: Object | null,
|
|
787
|
+
* }>,
|
|
788
|
+
* }}
|
|
789
|
+
*/
|
|
790
|
+
export function simulateCalculationExpression({
|
|
791
|
+
schema,
|
|
792
|
+
fieldDataName,
|
|
793
|
+
expression,
|
|
794
|
+
values = {},
|
|
795
|
+
security = DEFAULT_SECURITY_CONFIG,
|
|
796
|
+
}) {
|
|
797
|
+
const session = createCalculationPreviewSession({
|
|
798
|
+
schema,
|
|
799
|
+
fieldDataName,
|
|
800
|
+
expression,
|
|
801
|
+
security,
|
|
802
|
+
});
|
|
803
|
+
try {
|
|
804
|
+
return session.run({
|
|
805
|
+
expression,
|
|
806
|
+
values,
|
|
807
|
+
});
|
|
808
|
+
} finally {
|
|
809
|
+
session.dispose();
|
|
705
810
|
}
|
|
706
811
|
}
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { BUILTIN_CONTEXTS } from '../builtins/builtin-metadata.js';
|
|
2
|
+
import { BUILTIN_DEFINITION_BY_NAME, EVENT_BUILTIN_DEFINITIONS } from '../builtins/registry.js';
|
|
3
|
+
import { ContextResolver } from '../engine/context-resolver.js';
|
|
4
|
+
import { getAllEventTypes } from '../engine/event-registry.js';
|
|
5
|
+
import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
|
|
6
|
+
import { validateExpression } from '../security/validation.js';
|
|
7
|
+
import { flattenFields } from './field-helpers.js';
|
|
8
|
+
|
|
9
|
+
const COMMON_GLOBAL_FUNCTIONS = new Set([
|
|
10
|
+
'Math',
|
|
11
|
+
'Date',
|
|
12
|
+
'JSON',
|
|
13
|
+
'Number',
|
|
14
|
+
'String',
|
|
15
|
+
'Array',
|
|
16
|
+
'Object',
|
|
17
|
+
'Boolean',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const STRUCTURAL_FIELD_TYPES = new Set([
|
|
21
|
+
'Section',
|
|
22
|
+
'RepeatableSection',
|
|
23
|
+
'BuildingPlanSection',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const FORM_EVENT_BUILTIN_STATUS_BY_NAME = Object.freeze({
|
|
27
|
+
EVAL: 'advanced',
|
|
28
|
+
FORM: 'unavailable',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function getFormEventBuiltinStatus(name) {
|
|
32
|
+
return FORM_EVENT_BUILTIN_STATUS_BY_NAME[name] ?? 'stable';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toFormEventBuiltinDefinition(definition) {
|
|
36
|
+
return Object.freeze({
|
|
37
|
+
name: definition.name,
|
|
38
|
+
category: definition.category,
|
|
39
|
+
signature: definition.signature,
|
|
40
|
+
description: definition.description,
|
|
41
|
+
examples: Object.freeze([...definition.examples]),
|
|
42
|
+
status: getFormEventBuiltinStatus(definition.name),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const FORM_EVENT_BUILTIN_CATALOG = Object.freeze(
|
|
47
|
+
EVENT_BUILTIN_DEFINITIONS.map(toFormEventBuiltinDefinition)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const FORM_EVENT_BUILTIN_METADATA_BY_NAME = new Map(
|
|
51
|
+
FORM_EVENT_BUILTIN_CATALOG.map((definition) => [definition.name, definition])
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
function normalizeFormSchema(schema) {
|
|
55
|
+
if (schema && Array.isArray(schema.elements)) {
|
|
56
|
+
return schema;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (schema && schema.form && Array.isArray(schema.form.elements)) {
|
|
60
|
+
return schema.form;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new Error('Expected a form schema or a root schema containing form.elements');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isBuiltinCallMatchAllowed(functionName) {
|
|
67
|
+
return COMMON_GLOBAL_FUNCTIONS.has(functionName);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function removeEvalContents(code) {
|
|
71
|
+
let result = '';
|
|
72
|
+
let i = 0;
|
|
73
|
+
|
|
74
|
+
while (i < code.length) {
|
|
75
|
+
const evalMatch = code.substring(i).match(/^EVAL\s*\(/);
|
|
76
|
+
if (evalMatch) {
|
|
77
|
+
result += 'EVAL()';
|
|
78
|
+
i += evalMatch[0].length;
|
|
79
|
+
|
|
80
|
+
let parenCount = 1;
|
|
81
|
+
let inSingleQuote = false;
|
|
82
|
+
let inDoubleQuote = false;
|
|
83
|
+
let inBacktick = false;
|
|
84
|
+
|
|
85
|
+
while (i < code.length && parenCount > 0) {
|
|
86
|
+
const char = code[i];
|
|
87
|
+
|
|
88
|
+
if (char === "'" && !inDoubleQuote && !inBacktick) {
|
|
89
|
+
inSingleQuote = !inSingleQuote;
|
|
90
|
+
} else if (char === '"' && !inSingleQuote && !inBacktick) {
|
|
91
|
+
inDoubleQuote = !inDoubleQuote;
|
|
92
|
+
} else if (char === '`' && !inSingleQuote && !inDoubleQuote) {
|
|
93
|
+
inBacktick = !inBacktick;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
|
|
97
|
+
if (char === '(') {
|
|
98
|
+
parenCount++;
|
|
99
|
+
} else if (char === ')') {
|
|
100
|
+
parenCount--;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
i++;
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
result += code[i];
|
|
108
|
+
i++;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function extractFunctionCalls(code) {
|
|
116
|
+
const functionCallPattern = /\b([A-Z_][A-Z0-9_]*)\s*\(/g;
|
|
117
|
+
const matches = [];
|
|
118
|
+
let match;
|
|
119
|
+
|
|
120
|
+
while ((match = functionCallPattern.exec(code)) !== null) {
|
|
121
|
+
matches.push({
|
|
122
|
+
name: match[1],
|
|
123
|
+
index: match.index,
|
|
124
|
+
length: match[1].length,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return matches;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function extractFieldReferenceMatches(code) {
|
|
132
|
+
const cleanedCode = removeEvalContents(code);
|
|
133
|
+
const fieldRegex = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
134
|
+
const matches = [];
|
|
135
|
+
let match;
|
|
136
|
+
|
|
137
|
+
while ((match = fieldRegex.exec(cleanedCode)) !== null) {
|
|
138
|
+
matches.push({
|
|
139
|
+
fieldName: match[1],
|
|
140
|
+
reference: `$${match[1]}`,
|
|
141
|
+
index: match.index,
|
|
142
|
+
length: match[0].length,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return matches;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function extractLiteralEventTypeMatches(code) {
|
|
150
|
+
const matches = [];
|
|
151
|
+
const eventTypeCallPattern = /\b(ON|OFF)\s*\(\s*(['"])([^'"\\]*(?:\\.[^'"\\]*)*)\2/g;
|
|
152
|
+
let match;
|
|
153
|
+
|
|
154
|
+
while ((match = eventTypeCallPattern.exec(code)) !== null) {
|
|
155
|
+
const eventType = match[3];
|
|
156
|
+
const eventTypeIndex = match.index + match[0].indexOf(eventType);
|
|
157
|
+
matches.push({
|
|
158
|
+
builtinName: match[1],
|
|
159
|
+
eventType,
|
|
160
|
+
index: eventTypeIndex,
|
|
161
|
+
length: eventType.length,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return matches;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function cloneCatalogDefinition(definition) {
|
|
169
|
+
return {
|
|
170
|
+
...definition,
|
|
171
|
+
examples: [...definition.examples],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function toFormEventReferenceDefinition(field, resolver) {
|
|
176
|
+
return {
|
|
177
|
+
key: typeof field.key === 'string' ? field.key : field.data_name,
|
|
178
|
+
reference: `$${field.data_name}`,
|
|
179
|
+
dataName: field.data_name,
|
|
180
|
+
label: typeof field.label === 'string' ? field.label : field.data_name,
|
|
181
|
+
type: typeof field.type === 'string' ? field.type : 'UnknownField',
|
|
182
|
+
description: typeof field.description === 'string' ? field.description : null,
|
|
183
|
+
displayStyle:
|
|
184
|
+
field.display && typeof field.display === 'object' && typeof field.display.style === 'string'
|
|
185
|
+
? field.display.style
|
|
186
|
+
: typeof field.display === 'string'
|
|
187
|
+
? field.display
|
|
188
|
+
: null,
|
|
189
|
+
parentPath: resolver.getFieldInfo(field.data_name)?.parentPath || [],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function createIssue({ code, severity, message, symbol = null, index = null, length = null }) {
|
|
194
|
+
return {
|
|
195
|
+
code,
|
|
196
|
+
severity,
|
|
197
|
+
message,
|
|
198
|
+
symbol,
|
|
199
|
+
startIndex: typeof index === 'number' ? index : null,
|
|
200
|
+
endIndex: typeof index === 'number' && typeof length === 'number' ? index + length : null,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function addIssue(issues, issueKeys, issue) {
|
|
205
|
+
const key = `${issue.code}:${issue.symbol || issue.message}`;
|
|
206
|
+
if (issueKeys.has(key)) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
issueKeys.add(key);
|
|
210
|
+
issues.push(issue);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function validateSyntax(code) {
|
|
214
|
+
new Function(code);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function createSchemaFilteredSecurityConfig(securityConfig) {
|
|
218
|
+
if (!securityConfig || typeof securityConfig !== 'object') {
|
|
219
|
+
return DEFAULT_SECURITY_CONFIG;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
...securityConfig,
|
|
224
|
+
validateBuiltins: false,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @typedef {'stable' | 'advanced' | 'unavailable'} FormEventBuiltinStatus
|
|
230
|
+
*
|
|
231
|
+
* @typedef {Object} FormEventBuiltinDefinition
|
|
232
|
+
* @property {string} name
|
|
233
|
+
* @property {string} category
|
|
234
|
+
* @property {string} signature
|
|
235
|
+
* @property {string} description
|
|
236
|
+
* @property {string[]} examples
|
|
237
|
+
* @property {FormEventBuiltinStatus} status
|
|
238
|
+
*
|
|
239
|
+
* @typedef {Object} FormEventReferenceDefinition
|
|
240
|
+
* @property {string} key
|
|
241
|
+
* @property {string} reference
|
|
242
|
+
* @property {string} dataName
|
|
243
|
+
* @property {string} label
|
|
244
|
+
* @property {string} type
|
|
245
|
+
* @property {string | null} description
|
|
246
|
+
* @property {string | null} displayStyle
|
|
247
|
+
* @property {string[]} parentPath
|
|
248
|
+
*
|
|
249
|
+
* @typedef {Object} FormEventCodeIssue
|
|
250
|
+
* @property {string} code
|
|
251
|
+
* @property {'error' | 'warning'} severity
|
|
252
|
+
* @property {string} message
|
|
253
|
+
* @property {string | null} symbol
|
|
254
|
+
* @property {number | null} startIndex
|
|
255
|
+
* @property {number | null} endIndex
|
|
256
|
+
*/
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Return the canonical builtin catalog for form event authoring.
|
|
260
|
+
*
|
|
261
|
+
* @returns {FormEventBuiltinDefinition[]}
|
|
262
|
+
*/
|
|
263
|
+
export function getFormEventBuiltinCatalog() {
|
|
264
|
+
return FORM_EVENT_BUILTIN_CATALOG.map(cloneCatalogDefinition);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Return the list of field references available for form event authoring.
|
|
269
|
+
*
|
|
270
|
+
* @param {Object} options
|
|
271
|
+
* @param {Object} options.schema
|
|
272
|
+
* @returns {FormEventReferenceDefinition[]}
|
|
273
|
+
*/
|
|
274
|
+
export function getFormEventReferenceCatalog({ schema }) {
|
|
275
|
+
const form = normalizeFormSchema(schema);
|
|
276
|
+
const resolver = new ContextResolver(form);
|
|
277
|
+
|
|
278
|
+
return flattenFields(form.elements)
|
|
279
|
+
.filter((field) => field && typeof field === 'object')
|
|
280
|
+
.filter((field) => field.data_name)
|
|
281
|
+
.filter((field) => !STRUCTURAL_FIELD_TYPES.has(field.type))
|
|
282
|
+
.map((field) => toFormEventReferenceDefinition(field, resolver));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Statistically analyze form event code without executing it.
|
|
287
|
+
*
|
|
288
|
+
* @param {Object} options
|
|
289
|
+
* @param {string} options.code
|
|
290
|
+
* @param {Object} options.schema
|
|
291
|
+
* @param {Object} [options.securityConfig]
|
|
292
|
+
* @returns {{
|
|
293
|
+
* valid: boolean,
|
|
294
|
+
* issues: FormEventCodeIssue[],
|
|
295
|
+
* usedBuiltins: string[],
|
|
296
|
+
* referencedFields: string[],
|
|
297
|
+
* }}
|
|
298
|
+
*/
|
|
299
|
+
export function analyzeFormEventCode({
|
|
300
|
+
code,
|
|
301
|
+
schema,
|
|
302
|
+
securityConfig = DEFAULT_SECURITY_CONFIG,
|
|
303
|
+
}) {
|
|
304
|
+
const issues = [];
|
|
305
|
+
const issueKeys = new Set();
|
|
306
|
+
const usedBuiltins = [];
|
|
307
|
+
const usedBuiltinNames = new Set();
|
|
308
|
+
const referencedFields = [];
|
|
309
|
+
const referencedFieldNames = new Set();
|
|
310
|
+
const validEventTypes = new Set(getAllEventTypes());
|
|
311
|
+
const normalizedCode = typeof code === 'string' ? code : '';
|
|
312
|
+
const form = normalizeFormSchema(schema);
|
|
313
|
+
const knownFieldNames = new Set(
|
|
314
|
+
flattenFields(form.elements)
|
|
315
|
+
.filter((field) => field && typeof field === 'object')
|
|
316
|
+
.map((field) => field.data_name)
|
|
317
|
+
.filter((fieldName) => typeof fieldName === 'string' && fieldName.length > 0)
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
if (normalizedCode.trim().length > 0) {
|
|
321
|
+
try {
|
|
322
|
+
validateSyntax(normalizedCode);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
addIssue(
|
|
325
|
+
issues,
|
|
326
|
+
issueKeys,
|
|
327
|
+
createIssue({
|
|
328
|
+
code: 'invalid_syntax',
|
|
329
|
+
severity: 'error',
|
|
330
|
+
message:
|
|
331
|
+
error instanceof Error && error.message
|
|
332
|
+
? `Invalid form event syntax: ${error.message}`
|
|
333
|
+
: 'Invalid form event syntax.',
|
|
334
|
+
index: 0,
|
|
335
|
+
length: 1,
|
|
336
|
+
})
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const securityValidation = validateExpression(
|
|
342
|
+
normalizedCode,
|
|
343
|
+
createSchemaFilteredSecurityConfig(securityConfig),
|
|
344
|
+
true
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
if (!securityValidation.valid) {
|
|
348
|
+
addIssue(
|
|
349
|
+
issues,
|
|
350
|
+
issueKeys,
|
|
351
|
+
createIssue({
|
|
352
|
+
code: 'security_validation_failed',
|
|
353
|
+
severity: 'error',
|
|
354
|
+
message: securityValidation.reason,
|
|
355
|
+
index: 0,
|
|
356
|
+
length: 1,
|
|
357
|
+
})
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
for (const call of extractFunctionCalls(normalizedCode)) {
|
|
362
|
+
if (isBuiltinCallMatchAllowed(call.name)) {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const builtinDefinition = BUILTIN_DEFINITION_BY_NAME.get(call.name);
|
|
367
|
+
if (builtinDefinition) {
|
|
368
|
+
const isEventBuiltin = builtinDefinition.contexts.includes(BUILTIN_CONTEXTS.EVENT);
|
|
369
|
+
|
|
370
|
+
if (!isEventBuiltin && builtinDefinition.contexts.includes(BUILTIN_CONTEXTS.CALCULATION)) {
|
|
371
|
+
addIssue(
|
|
372
|
+
issues,
|
|
373
|
+
issueKeys,
|
|
374
|
+
createIssue({
|
|
375
|
+
code: 'calculation_builtin_not_allowed',
|
|
376
|
+
severity: 'error',
|
|
377
|
+
message: `${call.name}() is only available in CalculatedField expressions, not in form event code.`,
|
|
378
|
+
symbol: call.name,
|
|
379
|
+
index: call.index,
|
|
380
|
+
length: call.length,
|
|
381
|
+
})
|
|
382
|
+
);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const builtinMetadata = FORM_EVENT_BUILTIN_METADATA_BY_NAME.get(call.name);
|
|
387
|
+
if (!builtinMetadata) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!usedBuiltinNames.has(call.name)) {
|
|
392
|
+
usedBuiltinNames.add(call.name);
|
|
393
|
+
usedBuiltins.push(call.name);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (builtinMetadata.status === 'unavailable') {
|
|
397
|
+
addIssue(
|
|
398
|
+
issues,
|
|
399
|
+
issueKeys,
|
|
400
|
+
createIssue({
|
|
401
|
+
code: 'unavailable_builtin',
|
|
402
|
+
severity: 'error',
|
|
403
|
+
message: `${call.name}() is not available in form event code.`,
|
|
404
|
+
symbol: call.name,
|
|
405
|
+
index: call.index,
|
|
406
|
+
length: call.length,
|
|
407
|
+
})
|
|
408
|
+
);
|
|
409
|
+
} else if (builtinMetadata.status === 'advanced') {
|
|
410
|
+
addIssue(
|
|
411
|
+
issues,
|
|
412
|
+
issueKeys,
|
|
413
|
+
createIssue({
|
|
414
|
+
code: 'advanced_builtin',
|
|
415
|
+
severity: 'warning',
|
|
416
|
+
message: `${call.name}() is an advanced builtin. Prefer direct $field references when possible.`,
|
|
417
|
+
symbol: call.name,
|
|
418
|
+
index: call.index,
|
|
419
|
+
length: call.length,
|
|
420
|
+
})
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
addIssue(
|
|
428
|
+
issues,
|
|
429
|
+
issueKeys,
|
|
430
|
+
createIssue({
|
|
431
|
+
code: 'unknown_builtin',
|
|
432
|
+
severity: 'error',
|
|
433
|
+
message: `Unknown builtin function: ${call.name}.`,
|
|
434
|
+
symbol: call.name,
|
|
435
|
+
index: call.index,
|
|
436
|
+
length: call.length,
|
|
437
|
+
})
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
for (const reference of extractFieldReferenceMatches(normalizedCode)) {
|
|
442
|
+
if (!referencedFieldNames.has(reference.fieldName)) {
|
|
443
|
+
referencedFieldNames.add(reference.fieldName);
|
|
444
|
+
referencedFields.push(reference.fieldName);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!knownFieldNames.has(reference.fieldName)) {
|
|
448
|
+
addIssue(
|
|
449
|
+
issues,
|
|
450
|
+
issueKeys,
|
|
451
|
+
createIssue({
|
|
452
|
+
code: 'unknown_field_reference',
|
|
453
|
+
severity: 'error',
|
|
454
|
+
message: `Unknown field reference: ${reference.reference}.`,
|
|
455
|
+
symbol: reference.reference,
|
|
456
|
+
index: reference.index,
|
|
457
|
+
length: reference.length,
|
|
458
|
+
})
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
for (const eventTypeReference of extractLiteralEventTypeMatches(normalizedCode)) {
|
|
464
|
+
if (validEventTypes.has(eventTypeReference.eventType)) {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
addIssue(
|
|
469
|
+
issues,
|
|
470
|
+
issueKeys,
|
|
471
|
+
createIssue({
|
|
472
|
+
code: 'unknown_event_type',
|
|
473
|
+
severity: 'error',
|
|
474
|
+
message: `Unknown event type: ${eventTypeReference.eventType}.`,
|
|
475
|
+
symbol: eventTypeReference.eventType,
|
|
476
|
+
index: eventTypeReference.index,
|
|
477
|
+
length: eventTypeReference.length,
|
|
478
|
+
})
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
valid: issues.every((issue) => issue.severity !== 'error'),
|
|
484
|
+
issues,
|
|
485
|
+
usedBuiltins,
|
|
486
|
+
referencedFields,
|
|
487
|
+
};
|
|
488
|
+
}
|