form0-core 0.2.0 → 0.3.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/LICENSE +1 -1
- package/README.md +9 -9
- package/package.json +3 -3
- package/src/authoring/form-authoring.js +583 -0
- package/src/builtins/choice/choicevalue.js +4 -1
- package/src/builtins/choice/choicevalues.js +1 -1
- package/src/builtins/control/setresult.js +6 -0
- package/src/builtins/logical/if.js +4 -1
- package/src/builtins/registry.js +12 -2
- package/src/engine/evaluator.js +5 -1
- package/src/index.js +7 -0
- package/src/utilities/calculation-editor.js +94 -0
- package/src/utilities/form-event-editor.js +109 -5
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -81,15 +81,6 @@ console.log(engine.getState());
|
|
|
81
81
|
The engine API exposes `eval()` for calculations, conditions, and validation; `trigger()` for form
|
|
82
82
|
events; and `getState()` for current values and evaluated field state.
|
|
83
83
|
|
|
84
|
-
## 🔒 Security
|
|
85
|
-
|
|
86
|
-
> [!CAUTION]
|
|
87
|
-
> Schema expressions and event scripts execute JavaScript. Only evaluate schemas from authors you
|
|
88
|
-
> trust. `SAFE` and `CUSTOM` modes provide validation controls; they are not security sandboxes.
|
|
89
|
-
|
|
90
|
-
Read the [security policy](./SECURITY.md) before evaluating schemas outside a fully trusted
|
|
91
|
-
authoring workflow.
|
|
92
|
-
|
|
93
84
|
## Schema and record ownership
|
|
94
85
|
|
|
95
86
|
`form0-core` owns behavioral schema concerns such as fields, conditions, calculations, events, and
|
|
@@ -129,6 +120,15 @@ Record status remains top-level as `@status`; it is not stored inside `form_valu
|
|
|
129
120
|
- [Core concepts](https://docs.form0.dev/core/concepts)
|
|
130
121
|
- [Full documentation](https://docs.form0.dev)
|
|
131
122
|
|
|
123
|
+
## 🔒 Security
|
|
124
|
+
|
|
125
|
+
> [!CAUTION]
|
|
126
|
+
> Schema expressions and event scripts execute JavaScript. Only evaluate schemas from authors you
|
|
127
|
+
> trust. `SAFE` and `CUSTOM` modes provide validation controls; they are not security sandboxes.
|
|
128
|
+
|
|
129
|
+
Read the [security policy](./SECURITY.md) before evaluating schemas outside a fully trusted
|
|
130
|
+
authoring workflow.
|
|
131
|
+
|
|
132
132
|
## 🔗 Related repositories
|
|
133
133
|
|
|
134
134
|
- [form0-cli](https://github.com/paqu-io/form0-cli) — recommended project entry point
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form0-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"registry": "https://registry.npmjs.org/"
|
|
6
6
|
},
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"SECURITY.md"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
-
"test": "node tests/form0-core.test.js && node tests/numeric-field-schema.test.js && node tests/dataset-descriptors.test.js && node tests/structured-record.test.js && node tests/title-fields.test.js && node tests/builtin-metadata.test.js && node tests/calculation-expression-utils.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/security-test.js",
|
|
15
|
+
"test": "node tests/form0-core.test.js && node tests/numeric-field-schema.test.js && node tests/dataset-descriptors.test.js && node tests/structured-record.test.js && node tests/title-fields.test.js && node tests/builtin-metadata.test.js && node tests/calculation-expression-utils.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/form-authoring.test.js && node tests/security-test.js",
|
|
16
16
|
"format": "prettier --write \"src/**/*.{js,jsx}\" \"tests/**/*.{js,jsx}\" \"scripts/**/*.{js,mjs}\" package.json README.md SECURITY.md SUPPORT.md CONTRIBUTING.md eslint.config.js .github/workflows/ci.yml .github/workflows/publish.yml",
|
|
17
17
|
"format:check": "prettier --check \"src/**/*.{js,jsx}\" \"tests/**/*.{js,jsx}\" \"scripts/**/*.{js,mjs}\" package.json README.md SECURITY.md SUPPORT.md CONTRIBUTING.md eslint.config.js .github/workflows/ci.yml .github/workflows/publish.yml",
|
|
18
18
|
"lint:check": "eslint src tests",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"headless-forms",
|
|
44
44
|
"framework-agnostic"
|
|
45
45
|
],
|
|
46
|
-
"author": "paqu
|
|
46
|
+
"author": "paqu technologies <hello@paqu.io> (https://paqu.io/)",
|
|
47
47
|
"contributors": [
|
|
48
48
|
{
|
|
49
49
|
"name": "aragornii"
|
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { FIELD_SPECS } from '../schema/field-specs.js';
|
|
2
|
+
import { getValidOperators } from '../schema/operators.js';
|
|
3
|
+
import { validateSchema } from '../schema/schema-validator.js';
|
|
4
|
+
import { getAllEventTypes } from '../engine/event-registry.js';
|
|
5
|
+
import { generateKey } from '../utilities/hash.js';
|
|
6
|
+
import { flattenFields } from '../utilities/field-helpers.js';
|
|
7
|
+
import {
|
|
8
|
+
analyzeCalculationExpression,
|
|
9
|
+
getCalculationBuiltinCatalog,
|
|
10
|
+
getCalculationReferenceCatalog,
|
|
11
|
+
} from '../utilities/calculation-editor.js';
|
|
12
|
+
import {
|
|
13
|
+
analyzeFormEventCode,
|
|
14
|
+
getFormEventBuiltinCatalog,
|
|
15
|
+
getFormEventReferenceCatalog,
|
|
16
|
+
} from '../utilities/form-event-editor.js';
|
|
17
|
+
|
|
18
|
+
const CONTAINER_TYPES = new Set(['Section', 'RepeatableSection', 'BuildingPlanSection']);
|
|
19
|
+
const MUTATION_OPERATIONS = Object.freeze([
|
|
20
|
+
'updateForm',
|
|
21
|
+
'addField',
|
|
22
|
+
'updateField',
|
|
23
|
+
'moveField',
|
|
24
|
+
'removeField',
|
|
25
|
+
'setTitleField',
|
|
26
|
+
'setStatusField',
|
|
27
|
+
'setCalculation',
|
|
28
|
+
'setFormEventCode',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const CALCULATION_GUIDANCE = Object.freeze({
|
|
32
|
+
version: 1,
|
|
33
|
+
preferenceOrder: Object.freeze([
|
|
34
|
+
'direct-expression',
|
|
35
|
+
'builtin-expression',
|
|
36
|
+
'multiline-javascript',
|
|
37
|
+
]),
|
|
38
|
+
multilineResult: Object.freeze({
|
|
39
|
+
builtin: 'SETRESULT',
|
|
40
|
+
requiredCalls: 1,
|
|
41
|
+
placement: 'final-statement',
|
|
42
|
+
}),
|
|
43
|
+
nonPreferredPatterns: Object.freeze(['iife']),
|
|
44
|
+
examples: Object.freeze({
|
|
45
|
+
direct: '$quantity * $unit_price',
|
|
46
|
+
builtin: 'IF($eligible, $amount, 0)',
|
|
47
|
+
multiline: `let age = null;
|
|
48
|
+
if ($birth_date) {
|
|
49
|
+
const birth = new Date($birth_date);
|
|
50
|
+
const today = new Date();
|
|
51
|
+
age = today.getFullYear() - birth.getFullYear();
|
|
52
|
+
const month = today.getMonth() - birth.getMonth();
|
|
53
|
+
if (month < 0 || (month === 0 && today.getDate() < birth.getDate())) age--;
|
|
54
|
+
}
|
|
55
|
+
SETRESULT(age);`,
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const EVENT_GUIDANCE = Object.freeze({
|
|
60
|
+
version: 1,
|
|
61
|
+
choiceValues: Object.freeze({
|
|
62
|
+
single: Object.freeze({
|
|
63
|
+
fieldTypes: Object.freeze(['SingleChoiceField', 'BooleanField']),
|
|
64
|
+
builtin: 'CHOICEVALUE',
|
|
65
|
+
}),
|
|
66
|
+
multiple: Object.freeze({
|
|
67
|
+
fieldTypes: Object.freeze(['MultiChoiceField']),
|
|
68
|
+
builtin: 'CHOICEVALUES',
|
|
69
|
+
}),
|
|
70
|
+
}),
|
|
71
|
+
conditionalMappings: Object.freeze({
|
|
72
|
+
explicitNamedCases: true,
|
|
73
|
+
explicitFallback: true,
|
|
74
|
+
}),
|
|
75
|
+
examples: Object.freeze({
|
|
76
|
+
singleChoiceComparison: "CHOICEVALUE($birth_town) === 'loreto'",
|
|
77
|
+
multipleChoiceComparison: "CHOICEVALUES($interests).includes('music')",
|
|
78
|
+
conditionalChoiceMapping: `ON('change', 'birth_town', function () {
|
|
79
|
+
SETVALUE(
|
|
80
|
+
'comments',
|
|
81
|
+
IF(
|
|
82
|
+
CHOICEVALUE($birth_town) === 'loreto',
|
|
83
|
+
'Hooola!',
|
|
84
|
+
IF(CHOICEVALUE($birth_town) === 'recanati', 'Ciaooo', '')
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
});`,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function deepClone(value) {
|
|
92
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeRootSchema(schema) {
|
|
96
|
+
if (schema && schema.form && Array.isArray(schema.form.elements)) {
|
|
97
|
+
return schema;
|
|
98
|
+
}
|
|
99
|
+
if (schema && Array.isArray(schema.elements)) {
|
|
100
|
+
return { form: schema };
|
|
101
|
+
}
|
|
102
|
+
throw new Error('Expected a root schema containing form.elements or a form schema');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stableValue(value) {
|
|
106
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
107
|
+
if (!value || typeof value !== 'object') return value;
|
|
108
|
+
return Object.fromEntries(
|
|
109
|
+
Object.keys(value)
|
|
110
|
+
.sort()
|
|
111
|
+
.map((key) => [key, stableValue(value[key])])
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function fnv1a64(input) {
|
|
116
|
+
let hash = 0xcbf29ce484222325n;
|
|
117
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
118
|
+
hash ^= BigInt(input.charCodeAt(index));
|
|
119
|
+
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
|
|
120
|
+
}
|
|
121
|
+
return hash.toString(16).padStart(16, '0');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toSerializable(value) {
|
|
125
|
+
if (Array.isArray(value)) return value.map(toSerializable);
|
|
126
|
+
if (!value || typeof value !== 'object') return typeof value === 'function' ? undefined : value;
|
|
127
|
+
const result = {};
|
|
128
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
129
|
+
const serialized = toSerializable(entry);
|
|
130
|
+
if (serialized !== undefined) result[key] = serialized;
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function indexFields(elements) {
|
|
136
|
+
const byKey = new Map();
|
|
137
|
+
function visit(list, parentKey = null) {
|
|
138
|
+
if (!Array.isArray(list)) return;
|
|
139
|
+
list.forEach((field, index) => {
|
|
140
|
+
if (field?.key) byKey.set(field.key, { field, list, index, parentKey });
|
|
141
|
+
if (CONTAINER_TYPES.has(field?.type)) visit(field.elements, field.key || null);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
visit(elements);
|
|
145
|
+
return byKey;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function ensureFieldKeys(field) {
|
|
149
|
+
if (!field || typeof field !== 'object' || Array.isArray(field)) {
|
|
150
|
+
throw new Error('Field must be an object');
|
|
151
|
+
}
|
|
152
|
+
if (!field.key && field.data_name) field.key = generateKey(field.data_name);
|
|
153
|
+
if (CONTAINER_TYPES.has(field.type)) {
|
|
154
|
+
if (!Array.isArray(field.elements)) field.elements = [];
|
|
155
|
+
field.elements.forEach(ensureFieldKeys);
|
|
156
|
+
}
|
|
157
|
+
return field;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getInsertionPoint(form, parentKey, position = {}) {
|
|
161
|
+
let list = form.elements;
|
|
162
|
+
if (parentKey) {
|
|
163
|
+
const parent = indexFields(form.elements).get(parentKey)?.field;
|
|
164
|
+
if (!parent || !CONTAINER_TYPES.has(parent.type)) {
|
|
165
|
+
throw new Error(`Parent field '${parentKey}' is not a container`);
|
|
166
|
+
}
|
|
167
|
+
if (!Array.isArray(parent.elements)) parent.elements = [];
|
|
168
|
+
list = parent.elements;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (position.beforeKey || position.afterKey) {
|
|
172
|
+
const siblingKey = position.beforeKey || position.afterKey;
|
|
173
|
+
const index = list.findIndex((field) => field?.key === siblingKey);
|
|
174
|
+
if (index === -1) throw new Error(`Sibling field '${siblingKey}' was not found in the target`);
|
|
175
|
+
return { list, index: position.beforeKey ? index : index + 1 };
|
|
176
|
+
}
|
|
177
|
+
return { list, index: list.length };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function updateConditionReferences(value, oldValues, replacement) {
|
|
181
|
+
if (Array.isArray(value)) {
|
|
182
|
+
value.forEach((entry) => updateConditionReferences(entry, oldValues, replacement));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (!value || typeof value !== 'object') return;
|
|
186
|
+
if (value.type === 'TitleField' && Array.isArray(value.elements)) {
|
|
187
|
+
value.elements = value.elements.map((entry) => (oldValues.has(entry) ? replacement : entry));
|
|
188
|
+
}
|
|
189
|
+
for (const key of ['field_id', 'field_key', 'destination_field_id', 'form_link_field_key']) {
|
|
190
|
+
if (oldValues.has(value[key])) value[key] = replacement;
|
|
191
|
+
}
|
|
192
|
+
Object.values(value).forEach((entry) => updateConditionReferences(entry, oldValues, replacement));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function escapeRegExp(value) {
|
|
196
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function replaceCodeReferences(code, oldKey, oldDataName, newKey, newDataName) {
|
|
200
|
+
if (typeof code !== 'string') return code;
|
|
201
|
+
let updated = code.replace(
|
|
202
|
+
new RegExp(`\\$${escapeRegExp(oldDataName)}(?![A-Za-z0-9_])`, 'g'),
|
|
203
|
+
`$${newDataName}`
|
|
204
|
+
);
|
|
205
|
+
const replacements = [
|
|
206
|
+
{ name: oldDataName, replacement: newDataName },
|
|
207
|
+
{ name: oldKey, replacement: newKey },
|
|
208
|
+
].filter(({ name }) => typeof name === 'string' && name.length > 0);
|
|
209
|
+
for (const { name, replacement } of replacements) {
|
|
210
|
+
const escaped = escapeRegExp(name);
|
|
211
|
+
updated = updated.replace(
|
|
212
|
+
new RegExp(`((?:ON|OFF)\\(\\s*['\"][^'\"]+['\"]\\s*,\\s*)(['\"])${escaped}\\2`, 'g'),
|
|
213
|
+
`$1$2${replacement}$2`
|
|
214
|
+
);
|
|
215
|
+
updated = updated.replace(
|
|
216
|
+
new RegExp(`((?:SETVALUE)\\(\\s*)(['\"])${escaped}\\2`, 'g'),
|
|
217
|
+
`$1$2${replacement}$2`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return updated;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function renameReferences(form, oldField, newField) {
|
|
224
|
+
const oldValues = new Set([oldField.key, oldField.data_name].filter(Boolean));
|
|
225
|
+
updateConditionReferences(form, oldValues, newField.key);
|
|
226
|
+
for (const field of flattenFields(form.elements)) {
|
|
227
|
+
if (field.type === 'CalculatedField') {
|
|
228
|
+
field.calculate = replaceCodeReferences(
|
|
229
|
+
field.calculate,
|
|
230
|
+
oldField.key,
|
|
231
|
+
oldField.data_name,
|
|
232
|
+
newField.key,
|
|
233
|
+
newField.data_name
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (form.events?.code) {
|
|
238
|
+
form.events.code = replaceCodeReferences(
|
|
239
|
+
form.events.code,
|
|
240
|
+
oldField.key,
|
|
241
|
+
oldField.data_name,
|
|
242
|
+
newField.key,
|
|
243
|
+
newField.data_name
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function hasCodeReference(code, field) {
|
|
249
|
+
if (typeof code !== 'string') return false;
|
|
250
|
+
const names = [field.key, field.data_name].filter(Boolean).map(escapeRegExp);
|
|
251
|
+
return names.some(
|
|
252
|
+
(name) =>
|
|
253
|
+
new RegExp(`\\$${name}(?![A-Za-z0-9_])`).test(code) ||
|
|
254
|
+
new RegExp(`(?:ON|OFF|SETVALUE)\\([^)]*['\"]${name}['\"]`).test(code)
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function findRemovalReferences(form, field) {
|
|
259
|
+
const references = [];
|
|
260
|
+
const oldValues = new Set([field.key, field.data_name].filter(Boolean));
|
|
261
|
+
const scan = (value, path) => {
|
|
262
|
+
if (Array.isArray(value)) {
|
|
263
|
+
value.forEach((entry, index) => scan(entry, `${path}[${index}]`));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!value || typeof value !== 'object') return;
|
|
267
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
268
|
+
if (
|
|
269
|
+
['field_id', 'field_key', 'destination_field_id', 'form_link_field_key'].includes(key) &&
|
|
270
|
+
oldValues.has(entry)
|
|
271
|
+
) {
|
|
272
|
+
references.push(`${path}.${key}`);
|
|
273
|
+
}
|
|
274
|
+
scan(entry, `${path}.${key}`);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
scan(form, 'form');
|
|
278
|
+
const titleReferences = (value, path) => {
|
|
279
|
+
if (Array.isArray(value)) {
|
|
280
|
+
value.forEach((entry, index) => titleReferences(entry, `${path}[${index}]`));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!value || typeof value !== 'object') return;
|
|
284
|
+
if (value.type === 'TitleField' && value.elements?.some((entry) => oldValues.has(entry))) {
|
|
285
|
+
references.push(`${path}.elements`);
|
|
286
|
+
}
|
|
287
|
+
Object.entries(value).forEach(([key, entry]) => titleReferences(entry, `${path}.${key}`));
|
|
288
|
+
};
|
|
289
|
+
titleReferences(form, 'form');
|
|
290
|
+
for (const candidate of flattenFields(form.elements)) {
|
|
291
|
+
if (candidate !== field && hasCodeReference(candidate.calculate, field)) {
|
|
292
|
+
references.push(`CalculatedField:${candidate.data_name}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (hasCodeReference(form.events?.code, field)) references.push('form.events.code');
|
|
296
|
+
return [...new Set(references)];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function mutationError(index, operation, error) {
|
|
300
|
+
return {
|
|
301
|
+
code: 'mutation_failed',
|
|
302
|
+
severity: 'error',
|
|
303
|
+
operationIndex: index,
|
|
304
|
+
operation: operation?.op || null,
|
|
305
|
+
message: error instanceof Error ? error.message : String(error),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function applyMutation(root, operation) {
|
|
310
|
+
const form = root.form;
|
|
311
|
+
const fields = indexFields(form.elements);
|
|
312
|
+
switch (operation.op) {
|
|
313
|
+
case 'updateForm': {
|
|
314
|
+
const forbidden = new Set(['elements', 'events', 'title_field', 'status_field']);
|
|
315
|
+
for (const key of Object.keys(operation.changes || {})) {
|
|
316
|
+
if (forbidden.has(key)) throw new Error(`updateForm cannot change '${key}'`);
|
|
317
|
+
}
|
|
318
|
+
Object.assign(form, deepClone(operation.changes || {}));
|
|
319
|
+
return `Updated form attributes`;
|
|
320
|
+
}
|
|
321
|
+
case 'addField': {
|
|
322
|
+
const field = ensureFieldKeys(deepClone(operation.field));
|
|
323
|
+
if (field.key && fields.has(field.key))
|
|
324
|
+
throw new Error(`Field key '${field.key}' already exists`);
|
|
325
|
+
if (
|
|
326
|
+
field.data_name &&
|
|
327
|
+
[...fields.values()].some((entry) => entry.field.data_name === field.data_name)
|
|
328
|
+
) {
|
|
329
|
+
throw new Error(`Field data_name '${field.data_name}' already exists`);
|
|
330
|
+
}
|
|
331
|
+
const point = getInsertionPoint(form, operation.parentKey || null, operation.position || {});
|
|
332
|
+
point.list.splice(point.index, 0, field);
|
|
333
|
+
return `Added ${field.type || 'field'} '${field.data_name || field.key}'`;
|
|
334
|
+
}
|
|
335
|
+
case 'updateField': {
|
|
336
|
+
const target = fields.get(operation.fieldKey);
|
|
337
|
+
if (!target) throw new Error(`Field '${operation.fieldKey}' was not found`);
|
|
338
|
+
if (operation.changes?.type && operation.changes.type !== target.field.type) {
|
|
339
|
+
throw new Error('Field type cannot be changed; remove and add the field explicitly');
|
|
340
|
+
}
|
|
341
|
+
const original = deepClone(target.field);
|
|
342
|
+
const children = target.field.elements;
|
|
343
|
+
Object.assign(target.field, deepClone(operation.changes || {}));
|
|
344
|
+
if (
|
|
345
|
+
target.field.data_name !== original.data_name &&
|
|
346
|
+
[...fields.values()].some(
|
|
347
|
+
(entry) =>
|
|
348
|
+
entry.field !== target.field && entry.field.data_name === target.field.data_name
|
|
349
|
+
)
|
|
350
|
+
) {
|
|
351
|
+
throw new Error(`Field data_name '${target.field.data_name}' already exists`);
|
|
352
|
+
}
|
|
353
|
+
if (CONTAINER_TYPES.has(target.field.type)) target.field.elements = children || [];
|
|
354
|
+
if (target.field.data_name !== original.data_name) {
|
|
355
|
+
target.field.key = generateKey(target.field.data_name);
|
|
356
|
+
renameReferences(form, original, target.field);
|
|
357
|
+
} else {
|
|
358
|
+
target.field.key = original.key;
|
|
359
|
+
}
|
|
360
|
+
return `Updated field '${original.data_name}'`;
|
|
361
|
+
}
|
|
362
|
+
case 'moveField': {
|
|
363
|
+
const target = fields.get(operation.fieldKey);
|
|
364
|
+
if (!target) throw new Error(`Field '${operation.fieldKey}' was not found`);
|
|
365
|
+
const descendants = new Set(
|
|
366
|
+
flattenFields(target.field.elements || [])
|
|
367
|
+
.map((field) => field?.key)
|
|
368
|
+
.filter(Boolean)
|
|
369
|
+
);
|
|
370
|
+
if (operation.parentKey && descendants.has(operation.parentKey)) {
|
|
371
|
+
throw new Error('A field cannot be moved into one of its descendants');
|
|
372
|
+
}
|
|
373
|
+
target.list.splice(target.index, 1);
|
|
374
|
+
const point = getInsertionPoint(form, operation.parentKey || null, operation.position || {});
|
|
375
|
+
point.list.splice(point.index, 0, target.field);
|
|
376
|
+
return `Moved field '${target.field.data_name}'`;
|
|
377
|
+
}
|
|
378
|
+
case 'removeField': {
|
|
379
|
+
const target = fields.get(operation.fieldKey);
|
|
380
|
+
if (!target) throw new Error(`Field '${operation.fieldKey}' was not found`);
|
|
381
|
+
const references = findRemovalReferences(form, target.field);
|
|
382
|
+
if (references.length > 0) {
|
|
383
|
+
throw new Error(
|
|
384
|
+
`Field '${target.field.data_name}' is still referenced by: ${references.join(', ')}`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
target.list.splice(target.index, 1);
|
|
388
|
+
return `Removed field '${target.field.data_name}'`;
|
|
389
|
+
}
|
|
390
|
+
case 'setTitleField':
|
|
391
|
+
form.title_field = deepClone(operation.field ?? null);
|
|
392
|
+
return 'Updated title field';
|
|
393
|
+
case 'setStatusField':
|
|
394
|
+
form.status_field = deepClone(operation.field ?? null);
|
|
395
|
+
return 'Updated status field';
|
|
396
|
+
case 'setCalculation': {
|
|
397
|
+
const target = fields.get(operation.fieldKey)?.field;
|
|
398
|
+
if (!target || target.type !== 'CalculatedField') {
|
|
399
|
+
throw new Error(`Field '${operation.fieldKey}' is not a CalculatedField`);
|
|
400
|
+
}
|
|
401
|
+
target.calculate = operation.expression;
|
|
402
|
+
return `Updated calculation '${target.data_name}'`;
|
|
403
|
+
}
|
|
404
|
+
case 'setFormEventCode':
|
|
405
|
+
form.events = operation.code ? { ...(form.events || {}), code: operation.code } : null;
|
|
406
|
+
return 'Updated form event code';
|
|
407
|
+
default:
|
|
408
|
+
throw new Error(`Unsupported mutation operation '${operation.op}'`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function validateDraft(root, security) {
|
|
413
|
+
const diagnostics = [];
|
|
414
|
+
try {
|
|
415
|
+
validateSchema(root.form);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
diagnostics.push({
|
|
418
|
+
code: 'schema_validation_failed',
|
|
419
|
+
severity: 'error',
|
|
420
|
+
message: error.message,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
for (const field of flattenFields(root.form.elements)) {
|
|
424
|
+
if (field?.type !== 'CalculatedField') continue;
|
|
425
|
+
const analysis = analyzeCalculationExpression({
|
|
426
|
+
expression: field.calculate,
|
|
427
|
+
schema: root,
|
|
428
|
+
fieldDataName: field.data_name,
|
|
429
|
+
securityConfig: security,
|
|
430
|
+
});
|
|
431
|
+
diagnostics.push(
|
|
432
|
+
...analysis.issues.map((issue) => ({ ...issue, source: `calculation:${field.data_name}` }))
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
const eventAnalysis = analyzeFormEventCode({
|
|
436
|
+
code: root.form.events?.code || '',
|
|
437
|
+
schema: root,
|
|
438
|
+
securityConfig: security,
|
|
439
|
+
});
|
|
440
|
+
diagnostics.push(...eventAnalysis.issues.map((issue) => ({ ...issue, source: 'form-events' })));
|
|
441
|
+
return diagnostics;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Return a deterministic revision for optimistic authoring concurrency. */
|
|
445
|
+
export function getFormSchemaRevision(schema) {
|
|
446
|
+
const root = normalizeRootSchema(schema);
|
|
447
|
+
return `form0-${fnv1a64(JSON.stringify(stableValue(root)))}`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Return the installed-version authoring capabilities and complete schema context. */
|
|
451
|
+
export function getFormAuthoringContext({ schema, coreVersion = null }) {
|
|
452
|
+
const root = normalizeRootSchema(schema);
|
|
453
|
+
const fieldSpecs = Object.fromEntries(
|
|
454
|
+
Object.entries(FIELD_SPECS).map(([type, spec]) => [type, toSerializable(spec.attributes)])
|
|
455
|
+
);
|
|
456
|
+
const operators = Object.fromEntries(
|
|
457
|
+
Object.keys(FIELD_SPECS).map((type) => [type, getValidOperators(type)])
|
|
458
|
+
);
|
|
459
|
+
const calculations = flattenFields(root.form.elements)
|
|
460
|
+
.filter((field) => field?.type === 'CalculatedField')
|
|
461
|
+
.map((field) => ({
|
|
462
|
+
fieldKey: field.key,
|
|
463
|
+
fieldDataName: field.data_name,
|
|
464
|
+
references: getCalculationReferenceCatalog({
|
|
465
|
+
schema: root,
|
|
466
|
+
fieldDataName: field.data_name,
|
|
467
|
+
includeRestricted: true,
|
|
468
|
+
}),
|
|
469
|
+
}));
|
|
470
|
+
return {
|
|
471
|
+
contractVersion: 3,
|
|
472
|
+
coreVersion,
|
|
473
|
+
revision: getFormSchemaRevision(root),
|
|
474
|
+
schema: deepClone(root),
|
|
475
|
+
mutationOperations: [...MUTATION_OPERATIONS],
|
|
476
|
+
fieldSpecs,
|
|
477
|
+
operators,
|
|
478
|
+
eventTypes: getAllEventTypes(),
|
|
479
|
+
eventGuidance: deepClone(EVENT_GUIDANCE),
|
|
480
|
+
calculationBuiltins: getCalculationBuiltinCatalog(),
|
|
481
|
+
calculationGuidance: deepClone(CALCULATION_GUIDANCE),
|
|
482
|
+
eventBuiltins: getFormEventBuiltinCatalog(),
|
|
483
|
+
eventReferences: getFormEventReferenceCatalog({ schema: root }),
|
|
484
|
+
calculations,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Resolve whether the complete form may be sent to a cloud AI provider. */
|
|
489
|
+
export function getFormAICloudPolicy(schema) {
|
|
490
|
+
const root = normalizeRootSchema(schema);
|
|
491
|
+
const cloudBlockers = [];
|
|
492
|
+
const consentReasons = [];
|
|
493
|
+
if (root.form.ai?.allowCloud === false) cloudBlockers.push({ scope: 'form' });
|
|
494
|
+
if (root.form.ai?.requiresConsent === true) consentReasons.push({ scope: 'form' });
|
|
495
|
+
for (const field of flattenFields(root.form.elements)) {
|
|
496
|
+
const identity = field?.key || field?.data_name || 'unknown-field';
|
|
497
|
+
if (field?.ai?.allowCloud === false) cloudBlockers.push({ scope: 'field', key: identity });
|
|
498
|
+
if (field?.ai?.requiresConsent === true) consentReasons.push({ scope: 'field', key: identity });
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
allowCloud: cloudBlockers.length === 0,
|
|
502
|
+
requiresConsent: consentReasons.length > 0,
|
|
503
|
+
cloudBlockers,
|
|
504
|
+
consentReasons,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Validate a complete authoring draft without creating or applying mutations. */
|
|
509
|
+
export function validateFormAuthoringSchema({ schema, security }) {
|
|
510
|
+
const root = normalizeRootSchema(schema);
|
|
511
|
+
const diagnostics = validateDraft(root, security);
|
|
512
|
+
return {
|
|
513
|
+
valid: !diagnostics.some((diagnostic) => diagnostic.severity === 'error'),
|
|
514
|
+
revision: getFormSchemaRevision(root),
|
|
515
|
+
diagnostics,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Apply and fully validate a semantic mutation batch without mutating the input schema. */
|
|
520
|
+
export function applyFormMutationBatch({ schema, baseRevision, operations, security }) {
|
|
521
|
+
const root = normalizeRootSchema(schema);
|
|
522
|
+
const currentRevision = getFormSchemaRevision(root);
|
|
523
|
+
if (baseRevision !== currentRevision) {
|
|
524
|
+
return {
|
|
525
|
+
valid: false,
|
|
526
|
+
schema: null,
|
|
527
|
+
revision: currentRevision,
|
|
528
|
+
semanticDiff: [],
|
|
529
|
+
diagnostics: [
|
|
530
|
+
{
|
|
531
|
+
code: 'stale_schema_revision',
|
|
532
|
+
severity: 'error',
|
|
533
|
+
message: `Expected revision '${currentRevision}', received '${baseRevision}'`,
|
|
534
|
+
},
|
|
535
|
+
],
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
539
|
+
return {
|
|
540
|
+
valid: false,
|
|
541
|
+
schema: null,
|
|
542
|
+
revision: currentRevision,
|
|
543
|
+
semanticDiff: [],
|
|
544
|
+
diagnostics: [
|
|
545
|
+
{
|
|
546
|
+
code: 'empty_mutation_batch',
|
|
547
|
+
severity: 'error',
|
|
548
|
+
message: 'At least one operation is required',
|
|
549
|
+
},
|
|
550
|
+
],
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const draft = deepClone(root);
|
|
555
|
+
const semanticDiff = [];
|
|
556
|
+
for (let index = 0; index < operations.length; index += 1) {
|
|
557
|
+
try {
|
|
558
|
+
semanticDiff.push({
|
|
559
|
+
index,
|
|
560
|
+
op: operations[index]?.op,
|
|
561
|
+
summary: applyMutation(draft, operations[index]),
|
|
562
|
+
});
|
|
563
|
+
} catch (error) {
|
|
564
|
+
return {
|
|
565
|
+
valid: false,
|
|
566
|
+
schema: null,
|
|
567
|
+
revision: currentRevision,
|
|
568
|
+
semanticDiff: [],
|
|
569
|
+
diagnostics: [mutationError(index, operations[index], error)],
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const diagnostics = validateDraft(draft, security);
|
|
575
|
+
const valid = !diagnostics.some((diagnostic) => diagnostic.severity === 'error');
|
|
576
|
+
return {
|
|
577
|
+
valid,
|
|
578
|
+
schema: valid ? draft : null,
|
|
579
|
+
revision: valid ? getFormSchemaRevision(draft) : currentRevision,
|
|
580
|
+
semanticDiff: valid ? semanticDiff : [],
|
|
581
|
+
diagnostics,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
@@ -17,7 +17,10 @@ export const CHOICEVALUE_METADATA = defineBuiltinMetadata({
|
|
|
17
17
|
category: 'choice',
|
|
18
18
|
signature: 'CHOICEVALUE(fieldValue)',
|
|
19
19
|
description: 'Return the selected choice value from a choice field.',
|
|
20
|
-
examples: [
|
|
20
|
+
examples: [
|
|
21
|
+
'CHOICEVALUE($city)',
|
|
22
|
+
'IF(CHOICEVALUE($city) === "bogota", "Welcome to Bogota!", "Welcome!")',
|
|
23
|
+
],
|
|
21
24
|
contexts: [BUILTIN_CONTEXTS.CALCULATION, BUILTIN_CONTEXTS.EVENT],
|
|
22
25
|
});
|
|
23
26
|
|
|
@@ -17,7 +17,7 @@ export const CHOICEVALUES_METADATA = defineBuiltinMetadata({
|
|
|
17
17
|
category: 'choice',
|
|
18
18
|
signature: 'CHOICEVALUES(fieldValue)',
|
|
19
19
|
description: 'Return all selected values from a MultiChoiceField.',
|
|
20
|
-
examples: ['CHOICEVALUES($colors)'],
|
|
20
|
+
examples: ['CHOICEVALUES($colors)', 'CHOICEVALUES($colors).includes("red")'],
|
|
21
21
|
contexts: [BUILTIN_CONTEXTS.CALCULATION, BUILTIN_CONTEXTS.EVENT],
|
|
22
22
|
});
|
|
23
23
|
|
|
@@ -19,7 +19,10 @@ export const IF_METADATA = defineBuiltinMetadata({
|
|
|
19
19
|
category: 'logical',
|
|
20
20
|
signature: 'IF(condition, trueValue, falseValue)',
|
|
21
21
|
description: 'Return one of two values based on a condition.',
|
|
22
|
-
examples: [
|
|
22
|
+
examples: [
|
|
23
|
+
'IF($age >= 18, "adult", "minor")',
|
|
24
|
+
'IF($country === "US", IF($state === "CA", 8.99, 5.99), 15.99)',
|
|
25
|
+
],
|
|
23
26
|
contexts: [BUILTIN_CONTEXTS.CALCULATION, BUILTIN_CONTEXTS.EVENT],
|
|
24
27
|
});
|
|
25
28
|
|
package/src/builtins/registry.js
CHANGED
|
@@ -7,7 +7,12 @@ import { COUNT, COUNT_METADATA } from './logical/count.js';
|
|
|
7
7
|
import { COUNTA, COUNTA_METADATA } from './logical/counta.js';
|
|
8
8
|
import { COUNTBLANK, COUNTBLANK_METADATA } from './logical/countblank.js';
|
|
9
9
|
import { ARRAY, ARRAY_METADATA } from './logical/array.js';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
SETRESULT,
|
|
12
|
+
SETRESULT_METADATA,
|
|
13
|
+
__consumeResult,
|
|
14
|
+
__resetResult,
|
|
15
|
+
} from './control/setresult.js';
|
|
11
16
|
import { EVAL, EVAL_METADATA } from './control/eval.js';
|
|
12
17
|
import { CHOICEVALUE, CHOICEVALUE_METADATA } from './choice/choicevalue.js';
|
|
13
18
|
import { CHOICELABEL, CHOICELABEL_METADATA } from './choice/choicelabel.js';
|
|
@@ -37,7 +42,12 @@ export { COUNT, COUNT_METADATA } from './logical/count.js';
|
|
|
37
42
|
export { COUNTA, COUNTA_METADATA } from './logical/counta.js';
|
|
38
43
|
export { COUNTBLANK, COUNTBLANK_METADATA } from './logical/countblank.js';
|
|
39
44
|
export { ARRAY, ARRAY_METADATA } from './logical/array.js';
|
|
40
|
-
export {
|
|
45
|
+
export {
|
|
46
|
+
SETRESULT,
|
|
47
|
+
SETRESULT_METADATA,
|
|
48
|
+
__consumeResult,
|
|
49
|
+
__resetResult,
|
|
50
|
+
} from './control/setresult.js';
|
|
41
51
|
export { EVAL, EVAL_METADATA, __setEvalContext, __clearEvalContext } from './control/eval.js';
|
|
42
52
|
export { CHOICEVALUE, CHOICEVALUE_METADATA } from './choice/choicevalue.js';
|
|
43
53
|
export { CHOICELABEL, CHOICELABEL_METADATA } from './choice/choicelabel.js';
|
package/src/engine/evaluator.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { __consumeResult } from '../builtins/registry.js';
|
|
1
|
+
import { __consumeResult, __resetResult } from '../builtins/registry.js';
|
|
2
2
|
import { __setEvalContext, __clearEvalContext } from '../builtins/control/eval.js';
|
|
3
3
|
import { __setDataNamesContext, __clearDataNamesContext } from '../builtins/schema/datanames.js';
|
|
4
4
|
import { validateExpression, createSecureContext, withTimeout } from '../security/validation.js';
|
|
@@ -16,6 +16,9 @@ export function runExpression(
|
|
|
16
16
|
schema = null,
|
|
17
17
|
options = {}
|
|
18
18
|
) {
|
|
19
|
+
// SETRESULT uses synchronous evaluation-scoped state. Clear it at both boundaries so a
|
|
20
|
+
// previously failed expression can never influence this evaluation.
|
|
21
|
+
__resetResult();
|
|
19
22
|
try {
|
|
20
23
|
// Validate expression based on security mode
|
|
21
24
|
const validation = validateExpression(expr, securityConfig, includeEventBuiltins);
|
|
@@ -59,6 +62,7 @@ export function runExpression(
|
|
|
59
62
|
return consumed.called ? consumed.value : result;
|
|
60
63
|
}
|
|
61
64
|
} finally {
|
|
65
|
+
__resetResult();
|
|
62
66
|
// Always clear EVAL context after execution
|
|
63
67
|
__clearEvalContext();
|
|
64
68
|
if (schema) {
|
package/src/index.js
CHANGED
|
@@ -78,3 +78,10 @@ export {
|
|
|
78
78
|
getFieldAIChoicePolicy,
|
|
79
79
|
getFieldAIProviderHints,
|
|
80
80
|
} from './utilities/ai-metadata.js';
|
|
81
|
+
export {
|
|
82
|
+
getFormSchemaRevision,
|
|
83
|
+
getFormAuthoringContext,
|
|
84
|
+
getFormAICloudPolicy,
|
|
85
|
+
validateFormAuthoringSchema,
|
|
86
|
+
applyFormMutationBatch,
|
|
87
|
+
} from './authoring/form-authoring.js';
|
|
@@ -80,6 +80,69 @@ function extractFunctionCalls(code) {
|
|
|
80
80
|
return matches;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
function isFinalFunctionCall(code, call) {
|
|
84
|
+
let index = call.index + call.length;
|
|
85
|
+
while (index < code.length && /\s/u.test(code[index])) index += 1;
|
|
86
|
+
if (code[index] !== '(') return false;
|
|
87
|
+
|
|
88
|
+
let depth = 0;
|
|
89
|
+
let state = 'code';
|
|
90
|
+
for (; index < code.length; index += 1) {
|
|
91
|
+
const char = code[index];
|
|
92
|
+
const next = code[index + 1];
|
|
93
|
+
|
|
94
|
+
if (state === 'line-comment') {
|
|
95
|
+
if (char === '\n') state = 'code';
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (state === 'block-comment') {
|
|
99
|
+
if (char === '*' && next === '/') {
|
|
100
|
+
index += 1;
|
|
101
|
+
state = 'code';
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (state !== 'code') {
|
|
106
|
+
if (char === '\\') {
|
|
107
|
+
index += 1;
|
|
108
|
+
} else if (
|
|
109
|
+
(state === 'single-quote' && char === "'") ||
|
|
110
|
+
(state === 'double-quote' && char === '"') ||
|
|
111
|
+
(state === 'template' && char === '`')
|
|
112
|
+
) {
|
|
113
|
+
state = 'code';
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (char === '/' && next === '/') {
|
|
119
|
+
state = 'line-comment';
|
|
120
|
+
index += 1;
|
|
121
|
+
} else if (char === '/' && next === '*') {
|
|
122
|
+
state = 'block-comment';
|
|
123
|
+
index += 1;
|
|
124
|
+
} else if (char === "'") {
|
|
125
|
+
state = 'single-quote';
|
|
126
|
+
} else if (char === '"') {
|
|
127
|
+
state = 'double-quote';
|
|
128
|
+
} else if (char === '`') {
|
|
129
|
+
state = 'template';
|
|
130
|
+
} else if (char === '(') {
|
|
131
|
+
depth += 1;
|
|
132
|
+
} else if (char === ')') {
|
|
133
|
+
depth -= 1;
|
|
134
|
+
if (depth === 0) break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (depth !== 0) return false;
|
|
139
|
+
index += 1;
|
|
140
|
+
while (index < code.length && /\s/u.test(code[index])) index += 1;
|
|
141
|
+
if (code[index] === ';') index += 1;
|
|
142
|
+
while (index < code.length && /\s/u.test(code[index])) index += 1;
|
|
143
|
+
return index === code.length;
|
|
144
|
+
}
|
|
145
|
+
|
|
83
146
|
function cloneCatalogDefinition(definition) {
|
|
84
147
|
return {
|
|
85
148
|
...definition,
|
|
@@ -519,6 +582,37 @@ export function analyzeCalculationExpression({
|
|
|
519
582
|
);
|
|
520
583
|
}
|
|
521
584
|
|
|
585
|
+
if (isMultilineCalculationExpression(normalizedExpression) && setResultCalls.length === 0) {
|
|
586
|
+
addIssue(
|
|
587
|
+
issues,
|
|
588
|
+
issueKeys,
|
|
589
|
+
createIssue({
|
|
590
|
+
code: 'noncanonical_multiline_result',
|
|
591
|
+
severity: 'warning',
|
|
592
|
+
message: 'Multiline calculations should finish with exactly one SETRESULT() call.',
|
|
593
|
+
index: 0,
|
|
594
|
+
length: 1,
|
|
595
|
+
})
|
|
596
|
+
);
|
|
597
|
+
} else if (
|
|
598
|
+
isMultilineCalculationExpression(normalizedExpression) &&
|
|
599
|
+
setResultCalls.length === 1 &&
|
|
600
|
+
!isFinalFunctionCall(normalizedExpression, setResultCalls[0])
|
|
601
|
+
) {
|
|
602
|
+
addIssue(
|
|
603
|
+
issues,
|
|
604
|
+
issueKeys,
|
|
605
|
+
createIssue({
|
|
606
|
+
code: 'noncanonical_setresult_placement',
|
|
607
|
+
severity: 'warning',
|
|
608
|
+
message: 'SETRESULT() should be the final statement in a multiline calculation.',
|
|
609
|
+
symbol: 'SETRESULT',
|
|
610
|
+
index: setResultCalls[0].index,
|
|
611
|
+
length: setResultCalls[0].length,
|
|
612
|
+
})
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
522
616
|
for (const reference of extractStaticFieldReferenceMatches(normalizedExpression)) {
|
|
523
617
|
if (!referencedFieldNames.has(reference.fieldName)) {
|
|
524
618
|
referencedFieldNames.add(reference.fieldName);
|
|
@@ -18,6 +18,10 @@ const COMMON_GLOBAL_FUNCTIONS = new Set([
|
|
|
18
18
|
]);
|
|
19
19
|
|
|
20
20
|
const STRUCTURAL_FIELD_TYPES = new Set(['Section', 'RepeatableSection', 'BuildingPlanSection']);
|
|
21
|
+
const SINGLE_CHOICE_FIELD_TYPES = new Set(['SingleChoiceField', 'BooleanField']);
|
|
22
|
+
const MULTI_CHOICE_FIELD_TYPES = new Set(['MultiChoiceField']);
|
|
23
|
+
|
|
24
|
+
const SCALAR_LITERAL_PATTERN = String.raw`(?:'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|true|false|null|undefined)`;
|
|
21
25
|
|
|
22
26
|
const FORM_EVENT_BUILTIN_STATUS_BY_NAME = Object.freeze({
|
|
23
27
|
EVAL: 'advanced',
|
|
@@ -142,6 +146,84 @@ function extractFieldReferenceMatches(code) {
|
|
|
142
146
|
return matches;
|
|
143
147
|
}
|
|
144
148
|
|
|
149
|
+
function getCodePositions(code) {
|
|
150
|
+
const positions = new Array(code.length).fill(false);
|
|
151
|
+
let state = 'code';
|
|
152
|
+
|
|
153
|
+
for (let index = 0; index < code.length; index++) {
|
|
154
|
+
const char = code[index];
|
|
155
|
+
const next = code[index + 1];
|
|
156
|
+
|
|
157
|
+
if (state === 'line-comment') {
|
|
158
|
+
if (char === '\n') state = 'code';
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (state === 'block-comment') {
|
|
162
|
+
if (char === '*' && next === '/') {
|
|
163
|
+
index++;
|
|
164
|
+
state = 'code';
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (state !== 'code') {
|
|
169
|
+
if (char === '\\') {
|
|
170
|
+
index++;
|
|
171
|
+
} else if (
|
|
172
|
+
(state === 'single-quote' && char === "'") ||
|
|
173
|
+
(state === 'double-quote' && char === '"') ||
|
|
174
|
+
(state === 'template' && char === '`')
|
|
175
|
+
) {
|
|
176
|
+
state = 'code';
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
positions[index] = true;
|
|
182
|
+
if (char === '/' && next === '/') {
|
|
183
|
+
positions[index] = false;
|
|
184
|
+
state = 'line-comment';
|
|
185
|
+
index++;
|
|
186
|
+
} else if (char === '/' && next === '*') {
|
|
187
|
+
positions[index] = false;
|
|
188
|
+
state = 'block-comment';
|
|
189
|
+
index++;
|
|
190
|
+
} else if (char === "'") {
|
|
191
|
+
state = 'single-quote';
|
|
192
|
+
} else if (char === '"') {
|
|
193
|
+
state = 'double-quote';
|
|
194
|
+
} else if (char === '`') {
|
|
195
|
+
state = 'template';
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return positions;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function extractDirectScalarComparisonMatches(code) {
|
|
203
|
+
const comparisonPattern = new RegExp(
|
|
204
|
+
String.raw`(?:\$([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:===|!==|==|!=)\s*${SCALAR_LITERAL_PATTERN}|${SCALAR_LITERAL_PATTERN}\s*(?:===|!==|==|!=)\s*\$([a-zA-Z_][a-zA-Z0-9_]*))`,
|
|
205
|
+
'g'
|
|
206
|
+
);
|
|
207
|
+
const codePositions = getCodePositions(code);
|
|
208
|
+
const matches = [];
|
|
209
|
+
let match;
|
|
210
|
+
|
|
211
|
+
while ((match = comparisonPattern.exec(code)) !== null) {
|
|
212
|
+
if (!codePositions[match.index]) continue;
|
|
213
|
+
const fieldName = match[1] || match[2];
|
|
214
|
+
const reference = `$${fieldName}`;
|
|
215
|
+
const referenceOffset = match[0].indexOf(reference);
|
|
216
|
+
matches.push({
|
|
217
|
+
fieldName,
|
|
218
|
+
reference,
|
|
219
|
+
index: match.index + referenceOffset,
|
|
220
|
+
length: reference.length,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return matches;
|
|
225
|
+
}
|
|
226
|
+
|
|
145
227
|
function extractLiteralEventTypeMatches(code) {
|
|
146
228
|
const matches = [];
|
|
147
229
|
const eventTypeCallPattern = /\b(ON|OFF)\s*\(\s*(['"])([^'"\\]*(?:\\.[^'"\\]*)*)\2/g;
|
|
@@ -302,12 +384,11 @@ export function analyzeFormEventCode({ code, schema, securityConfig = DEFAULT_SE
|
|
|
302
384
|
const validEventTypes = new Set(getAllEventTypes());
|
|
303
385
|
const normalizedCode = typeof code === 'string' ? code : '';
|
|
304
386
|
const form = normalizeFormSchema(schema);
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
.filter((field) => field && typeof field === 'object')
|
|
308
|
-
.map((field) => field.data_name)
|
|
309
|
-
.filter((fieldName) => typeof fieldName === 'string' && fieldName.length > 0)
|
|
387
|
+
const knownFields = flattenFields(form.elements).filter(
|
|
388
|
+
(field) => field && typeof field === 'object' && typeof field.data_name === 'string'
|
|
310
389
|
);
|
|
390
|
+
const knownFieldNames = new Set(knownFields.map((field) => field.data_name));
|
|
391
|
+
const knownFieldByName = new Map(knownFields.map((field) => [field.data_name, field]));
|
|
311
392
|
|
|
312
393
|
if (normalizedCode.trim().length > 0) {
|
|
313
394
|
try {
|
|
@@ -452,6 +533,29 @@ export function analyzeFormEventCode({ code, schema, securityConfig = DEFAULT_SE
|
|
|
452
533
|
}
|
|
453
534
|
}
|
|
454
535
|
|
|
536
|
+
for (const comparison of extractDirectScalarComparisonMatches(normalizedCode)) {
|
|
537
|
+
const fieldType = knownFieldByName.get(comparison.fieldName)?.type;
|
|
538
|
+
const accessor = SINGLE_CHOICE_FIELD_TYPES.has(fieldType)
|
|
539
|
+
? 'CHOICEVALUE'
|
|
540
|
+
: MULTI_CHOICE_FIELD_TYPES.has(fieldType)
|
|
541
|
+
? 'CHOICEVALUES'
|
|
542
|
+
: null;
|
|
543
|
+
if (!accessor) continue;
|
|
544
|
+
|
|
545
|
+
addIssue(
|
|
546
|
+
issues,
|
|
547
|
+
issueKeys,
|
|
548
|
+
createIssue({
|
|
549
|
+
code: 'choice_value_accessor_required',
|
|
550
|
+
severity: 'error',
|
|
551
|
+
message: `${comparison.reference} is a ${fieldType} value object. Use ${accessor}(${comparison.reference}) when comparing its selected value.`,
|
|
552
|
+
symbol: comparison.reference,
|
|
553
|
+
index: comparison.index,
|
|
554
|
+
length: comparison.length,
|
|
555
|
+
})
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
455
559
|
for (const eventTypeReference of extractLiteralEventTypeMatches(normalizedCode)) {
|
|
456
560
|
if (validEventTypes.has(eventTypeReference.eventType)) {
|
|
457
561
|
continue;
|