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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "form0-core",
3
- "version": "0.1.7",
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, BUILTIN_CONTEXTS.EVENT],
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',
@@ -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 CALCULATION_AND_EVENT_ENTRIES = Object.freeze([
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 builtins = buildBuiltinObject(CALCULATION_AND_EVENT_ENTRIES);
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
- export const eventBuiltins = buildBuiltinObject(EVENT_ONLY_ENTRIES);
126
+ // Backward-compatible alias for calculation runtime helpers.
127
+ export const builtins = calculationBuiltins;
116
128
 
117
129
  export const BUILTIN_DEFINITIONS = Object.freeze(
118
- [...CALCULATION_AND_EVENT_ENTRIES, ...EVENT_ONLY_ENTRIES].map(({ definition }) => definition)
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 { builtins, eventBuiltins } from '../builtins/registry.js';
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 = { ...builtins, ...helpers };
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 = { ...builtins, ...eventBuiltins, ...helpers };
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 { builtins, eventBuiltins } from '../builtins/registry.js';
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 validBuiltinsSet = new Set(Object.keys(builtins));
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
- ? new Set([...validBuiltinsSet, ...validEventBuiltinsSet])
13
- : validBuiltinsSet;
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 validBuiltins = [...allowedBuiltins]; // Convert back to array for filtering
50
- const similarBuiltins = validBuiltins.filter((builtin) => {
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
+ }