form0-core 0.2.1 → 0.3.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025-present paqu.io
3
+ Copyright (c) 2025-present paqu technologies
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "form0-core",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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/conditions.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.io <hello@paqu.io> (https://paqu.io/)",
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: ['CHOICEVALUE($city)'],
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
 
@@ -42,3 +42,9 @@ export function __consumeResult() {
42
42
  _resultValue = undefined;
43
43
  return { called, value };
44
44
  }
45
+
46
+ /** Clear calculation result state without consuming it. */
47
+ export function __resetResult() {
48
+ _resultSet = false;
49
+ _resultValue = undefined;
50
+ }
@@ -181,11 +181,14 @@ IF(user.age >= 18, "Adult", "Minor")
181
181
 
182
182
  ## Security
183
183
 
184
- All builtins are designed to be safe for use in form contexts:
184
+ Builtins provide defined interfaces for common form operations, and their arguments are validated
185
+ where applicable. Event builtins such as `SETVALUE` and `ALERT` return operations for the host to
186
+ apply instead of mutating application state directly.
185
187
 
186
- - **No side effects**: Builtins don't modify external state
187
- - **Sandboxed execution**: Cannot access system resources
188
- - **Input validation**: Arguments are validated before processing
188
+ Using a builtin does not sandbox the JavaScript expression or event script that calls it. Schemas
189
+ containing executable code must come from trusted authors. `SAFE` and `CUSTOM` modes provide
190
+ validation and configuration controls, not isolation. See the project
191
+ [security policy](../../../SECURITY.md) for the trust model and application responsibilities.
189
192
 
190
193
  ## Extending Builtins
191
194
 
@@ -252,4 +255,4 @@ SETRESULT(
252
255
 
253
256
  - Explore individual builtin documentation for detailed usage
254
257
  - Check out the [form0 examples](../examples.mdx) for real-world scenarios
255
- - Learn about [custom helpers](../custom-helpers.mdx) for extending functionality
258
+ - Learn about [custom helpers](../custom-helpers.mdx) for extending functionality
@@ -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: ['IF($age >= 18, "adult", "minor")'],
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
 
@@ -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 { SETRESULT, SETRESULT_METADATA, __consumeResult } from './control/setresult.js';
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 { SETRESULT, SETRESULT_METADATA, __consumeResult } from './control/setresult.js';
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';
@@ -1,8 +1,28 @@
1
1
  import { flattenFields } from '../utilities/field-helpers.js';
2
2
 
3
+ const valuesEqual = (a, b) => {
4
+ if (Array.isArray(a) && Array.isArray(b)) {
5
+ if (a.length !== b.length) {
6
+ return false;
7
+ }
8
+
9
+ const unmatched = [...b];
10
+ return a.every((value) => {
11
+ const matchIndex = unmatched.findIndex((candidate) => candidate === value);
12
+ if (matchIndex === -1) {
13
+ return false;
14
+ }
15
+ unmatched.splice(matchIndex, 1);
16
+ return true;
17
+ });
18
+ }
19
+
20
+ return a === b;
21
+ };
22
+
3
23
  const OPERATORS = {
4
- equal_to: (a, b) => a === b,
5
- not_equal_to: (a, b) => a !== b,
24
+ equal_to: valuesEqual,
25
+ not_equal_to: (a, b) => !valuesEqual(a, b),
6
26
  greater_than: (a, b) => a > b,
7
27
  less_than: (a, b) => a < b,
8
28
  greater_or_equal_than: (a, b) => a >= b,
@@ -15,6 +35,52 @@ const OPERATORS = {
15
35
  !(a === null || a === undefined || a === '' || (Array.isArray(a) && a.length === 0)),
16
36
  };
17
37
 
38
+ const hasValue = (entry, property) =>
39
+ entry &&
40
+ typeof entry === 'object' &&
41
+ Object.prototype.hasOwnProperty.call(entry, property) &&
42
+ entry[property] !== null &&
43
+ entry[property] !== undefined &&
44
+ entry[property] !== '';
45
+
46
+ const getOtherValue = (entry) => {
47
+ if (hasValue(entry, 'value')) {
48
+ return entry.value;
49
+ }
50
+ if (hasValue(entry, 'label')) {
51
+ return entry.label;
52
+ }
53
+ return null;
54
+ };
55
+
56
+ function normalizeConditionValue(field, value) {
57
+ if (!field || !value || typeof value !== 'object' || Array.isArray(value)) {
58
+ return value;
59
+ }
60
+
61
+ if (field.type === 'SingleChoiceField' || field.type === 'BooleanField') {
62
+ const selectedChoice = Array.isArray(value.choice) ? value.choice[0] : null;
63
+ if (hasValue(selectedChoice, 'value')) {
64
+ return selectedChoice.value;
65
+ }
66
+
67
+ const otherEntry = Array.isArray(value.other) ? value.other[0] : null;
68
+ return getOtherValue(otherEntry);
69
+ }
70
+
71
+ if (field.type === 'MultiChoiceField') {
72
+ const selectedValues = Array.isArray(value.choices)
73
+ ? value.choices.filter((choice) => hasValue(choice, 'value')).map((choice) => choice.value)
74
+ : [];
75
+ const otherValues = Array.isArray(value.other)
76
+ ? value.other.map(getOtherValue).filter((otherValue) => otherValue !== null)
77
+ : [];
78
+ return [...selectedValues, ...otherValues];
79
+ }
80
+
81
+ return value;
82
+ }
83
+
18
84
  export function evaluateConditions(conditions, values, allFields) {
19
85
  if (Array.isArray(conditions)) {
20
86
  return conditions.every((c) => evaluateConditions(c, values, allFields));
@@ -31,7 +97,7 @@ export function evaluateConditions(conditions, values, allFields) {
31
97
  // Map field_id (key) to data_name for value lookup
32
98
  const field = allFields ? allFields[conditions.field_id] : undefined;
33
99
  const dataName = field ? field.data_name : undefined;
34
- const val = dataName ? values[dataName] : undefined;
100
+ const val = normalizeConditionValue(field, dataName ? values[dataName] : undefined);
35
101
  // Debug log: show what is being checked
36
102
  //console.log('[evaluateConditions] field_id:', conditions.field_id, 'data_name:', dataName, 'value in values:', val, 'expected:', conditions.value, 'operator:', conditions.operator);
37
103
  const fn = OPERATORS[conditions.operator];
@@ -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) {
@@ -415,14 +415,16 @@ export class EventManager {
415
415
 
416
416
  // Consume any collected event operations
417
417
  const collectedOps = __consumeEventOperations();
418
+ const runtimeOps = collectedOps.filter(
419
+ (operation) => operation?.type !== 'EVENT_OPERATION'
420
+ );
418
421
 
419
422
  // Validate operations against context restrictions before adding them
420
- const validOps = this.validateOperations(collectedOps, executionContext);
423
+ const validOps = this.validateOperations(runtimeOps, executionContext);
421
424
  operations.push(...validOps);
422
425
 
423
- // Note: EVENT_OPERATION processing (ON/OFF) is handled only during initialization
424
- // If event handlers contain ON/OFF calls, they will be in the operations array
425
- // but won't be processed here to avoid duplicate registrations
426
+ // EVENT_OPERATION processing is initialization-only. ON/OFF calls inside a handler neither
427
+ // mutate the listener registry nor leak internal registration descriptors to the host.
426
428
 
427
429
  // For backward compatibility, still handle returned operations
428
430
  if (result && result.type === 'UI_OPERATION') {
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 knownFieldNames = new Set(
306
- flattenFields(form.elements)
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;