form0-core 0.1.7 → 0.1.9

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.
@@ -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
+ }
@@ -0,0 +1,54 @@
1
+ function isPlainObject(value) {
2
+ if (!value || typeof value !== 'object') {
3
+ return false;
4
+ }
5
+
6
+ return Object.prototype.toString.call(value) === '[object Object]';
7
+ }
8
+
9
+ export function areStructuredValuesEqual(left, right) {
10
+ if (Object.is(left, right)) {
11
+ return true;
12
+ }
13
+
14
+ if (left instanceof Date && right instanceof Date) {
15
+ return left.getTime() === right.getTime();
16
+ }
17
+
18
+ if (Array.isArray(left) && Array.isArray(right)) {
19
+ if (left.length !== right.length) {
20
+ return false;
21
+ }
22
+
23
+ for (let index = 0; index < left.length; index += 1) {
24
+ if (!areStructuredValuesEqual(left[index], right[index])) {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ return true;
30
+ }
31
+
32
+ if (isPlainObject(left) && isPlainObject(right)) {
33
+ const leftKeys = Object.keys(left);
34
+ const rightKeys = Object.keys(right);
35
+
36
+ if (leftKeys.length !== rightKeys.length) {
37
+ return false;
38
+ }
39
+
40
+ for (const key of leftKeys) {
41
+ if (!Object.prototype.hasOwnProperty.call(right, key)) {
42
+ return false;
43
+ }
44
+
45
+ if (!areStructuredValuesEqual(left[key], right[key])) {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ return true;
51
+ }
52
+
53
+ return false;
54
+ }