form0-core 0.1.7 → 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 +5 -0
- package/src/security/validation.js +6 -7
- 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
|
@@ -38,6 +38,11 @@ export {
|
|
|
38
38
|
createCalculationPreviewSession,
|
|
39
39
|
simulateCalculationExpression,
|
|
40
40
|
} from './utilities/calculation-editor.js';
|
|
41
|
+
export {
|
|
42
|
+
getFormEventBuiltinCatalog,
|
|
43
|
+
getFormEventReferenceCatalog,
|
|
44
|
+
analyzeFormEventCode,
|
|
45
|
+
} from './utilities/form-event-editor.js';
|
|
41
46
|
export {
|
|
42
47
|
getFormAIMetadata,
|
|
43
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));
|
|
@@ -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
|
+
}
|