form0-core 0.1.1 → 0.1.3

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.
Files changed (35) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +14 -0
  3. package/package.json +2 -2
  4. package/src/builtins/builtin-metadata.js +44 -0
  5. package/src/builtins/choice/choicelabel.js +11 -0
  6. package/src/builtins/choice/choicelabels.js +11 -0
  7. package/src/builtins/choice/choicevalue.js +11 -0
  8. package/src/builtins/choice/choicevalues.js +11 -0
  9. package/src/builtins/choice/hasother.js +11 -0
  10. package/src/builtins/choice/other.js +11 -0
  11. package/src/builtins/control/eval.js +10 -0
  12. package/src/builtins/control/setresult.js +11 -0
  13. package/src/builtins/event/control/off.js +10 -0
  14. package/src/builtins/event/control/on.js +10 -0
  15. package/src/builtins/event/field/setvalue.js +10 -0
  16. package/src/builtins/event/ui/alert.js +10 -0
  17. package/src/builtins/logical/and.js +11 -0
  18. package/src/builtins/logical/array.js +11 -0
  19. package/src/builtins/logical/count.js +11 -0
  20. package/src/builtins/logical/counta.js +11 -0
  21. package/src/builtins/logical/countblank.js +12 -3
  22. package/src/builtins/logical/if.js +11 -0
  23. package/src/builtins/logical/or.js +11 -0
  24. package/src/builtins/math/abs.js +11 -0
  25. package/src/builtins/math/ceiling.js +11 -0
  26. package/src/builtins/math/cos.js +12 -1
  27. package/src/builtins/math/round.js +13 -2
  28. package/src/builtins/math/sin.js +12 -1
  29. package/src/builtins/registry.js +130 -92
  30. package/src/builtins/schema/datanames.js +10 -0
  31. package/src/builtins/schema/form.js +11 -0
  32. package/src/builtins/string/upper.js +12 -1
  33. package/src/index.js +6 -0
  34. package/src/utilities/calculation-editor.js +501 -0
  35. package/src/utilities/record-transformer.js +32 -11
@@ -0,0 +1,501 @@
1
+ import { BUILTIN_CONTEXTS } from '../builtins/builtin-metadata.js';
2
+ import {
3
+ BUILTIN_DEFINITION_BY_NAME,
4
+ CALCULATION_BUILTIN_DEFINITIONS,
5
+ } from '../builtins/registry.js';
6
+ import { ContextResolver } from '../engine/context-resolver.js';
7
+ import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
8
+ import { validateExpression } from '../security/validation.js';
9
+ import { flattenFields } from './field-helpers.js';
10
+
11
+ const COMMON_GLOBAL_FUNCTIONS = new Set([
12
+ 'Math',
13
+ 'Date',
14
+ 'JSON',
15
+ 'Number',
16
+ 'String',
17
+ 'Array',
18
+ 'Object',
19
+ 'Boolean',
20
+ ]);
21
+
22
+ const CALCULATION_BUILTIN_STATUS_BY_NAME = Object.freeze({
23
+ EVAL: 'advanced',
24
+ FORM: 'unavailable',
25
+ });
26
+
27
+ function getCalculationBuiltinStatus(name) {
28
+ return CALCULATION_BUILTIN_STATUS_BY_NAME[name] ?? 'stable';
29
+ }
30
+
31
+ function toCalculationBuiltinDefinition(definition) {
32
+ return Object.freeze({
33
+ name: definition.name,
34
+ category: definition.category,
35
+ signature: definition.signature,
36
+ description: definition.description,
37
+ examples: Object.freeze([...definition.examples]),
38
+ status: getCalculationBuiltinStatus(definition.name),
39
+ });
40
+ }
41
+
42
+ const CALCULATION_BUILTIN_CATALOG = Object.freeze(
43
+ CALCULATION_BUILTIN_DEFINITIONS.map(toCalculationBuiltinDefinition)
44
+ );
45
+
46
+ const CALCULATION_BUILTIN_METADATA_BY_NAME = new Map(
47
+ CALCULATION_BUILTIN_CATALOG.map((definition) => [definition.name, definition])
48
+ );
49
+
50
+ function normalizeFormSchema(schema) {
51
+ if (schema && Array.isArray(schema.elements)) {
52
+ return schema;
53
+ }
54
+
55
+ if (schema && schema.form && Array.isArray(schema.form.elements)) {
56
+ return schema.form;
57
+ }
58
+
59
+ throw new Error('Expected a form schema or a root schema containing form.elements');
60
+ }
61
+
62
+ function isBuiltinCallMatchAllowed(functionName) {
63
+ return COMMON_GLOBAL_FUNCTIONS.has(functionName);
64
+ }
65
+
66
+ function removeEvalContents(code) {
67
+ let result = '';
68
+ let i = 0;
69
+
70
+ while (i < code.length) {
71
+ const evalMatch = code.substring(i).match(/^EVAL\s*\(/);
72
+ if (evalMatch) {
73
+ result += 'EVAL()';
74
+ i += evalMatch[0].length;
75
+
76
+ let parenCount = 1;
77
+ let inSingleQuote = false;
78
+ let inDoubleQuote = false;
79
+ let inBacktick = false;
80
+
81
+ while (i < code.length && parenCount > 0) {
82
+ const char = code[i];
83
+
84
+ if (char === "'" && !inDoubleQuote && !inBacktick) {
85
+ inSingleQuote = !inSingleQuote;
86
+ } else if (char === '"' && !inSingleQuote && !inBacktick) {
87
+ inDoubleQuote = !inDoubleQuote;
88
+ } else if (char === '`' && !inSingleQuote && !inDoubleQuote) {
89
+ inBacktick = !inBacktick;
90
+ }
91
+
92
+ if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
93
+ if (char === '(') {
94
+ parenCount++;
95
+ } else if (char === ')') {
96
+ parenCount--;
97
+ }
98
+ }
99
+
100
+ i++;
101
+ }
102
+ } else {
103
+ result += code[i];
104
+ i++;
105
+ }
106
+ }
107
+
108
+ return result;
109
+ }
110
+
111
+ function extractFunctionCalls(code) {
112
+ const functionCallPattern = /\b([A-Z_][A-Z0-9_]*)\s*\(/g;
113
+ const matches = [];
114
+ let match;
115
+
116
+ while ((match = functionCallPattern.exec(code)) !== null) {
117
+ matches.push({
118
+ name: match[1],
119
+ index: match.index,
120
+ length: match[1].length,
121
+ });
122
+ }
123
+
124
+ return matches;
125
+ }
126
+
127
+ function extractFieldReferenceMatches(code) {
128
+ const cleanedCode = removeEvalContents(code);
129
+ const fieldRegex = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
130
+ const matches = [];
131
+ let match;
132
+
133
+ while ((match = fieldRegex.exec(cleanedCode)) !== null) {
134
+ matches.push({
135
+ fieldName: match[1],
136
+ reference: `$${match[1]}`,
137
+ index: match.index,
138
+ length: match[0].length,
139
+ });
140
+ }
141
+
142
+ return matches;
143
+ }
144
+
145
+ function buildExecutionContext(form, fieldDataName, resolver) {
146
+ return {
147
+ type: 'calculation',
148
+ fieldName: fieldDataName,
149
+ parentPath: resolver.getFieldInfo(fieldDataName)?.parentPath || [],
150
+ };
151
+ }
152
+
153
+ function cloneCatalogDefinition(definition) {
154
+ return {
155
+ ...definition,
156
+ examples: [...definition.examples],
157
+ };
158
+ }
159
+
160
+ function createIssue({ code, severity, message, symbol = null, index = null, length = null }) {
161
+ return {
162
+ code,
163
+ severity,
164
+ message,
165
+ symbol,
166
+ startIndex: typeof index === 'number' ? index : null,
167
+ endIndex: typeof index === 'number' && typeof length === 'number' ? index + length : null,
168
+ };
169
+ }
170
+
171
+ function addIssue(issues, issueKeys, issue) {
172
+ const key = `${issue.code}:${issue.symbol || issue.message}`;
173
+ if (issueKeys.has(key)) {
174
+ return;
175
+ }
176
+ issueKeys.add(key);
177
+ issues.push(issue);
178
+ }
179
+
180
+ function validateSyntax(expression) {
181
+ const isMultiLine =
182
+ expression.includes('\r\n') || expression.includes('\n') || expression.includes('function');
183
+
184
+ if (isMultiLine) {
185
+ new Function(expression);
186
+ return;
187
+ }
188
+
189
+ new Function(`return (${expression});`);
190
+ }
191
+
192
+ function createSchemaFilteredSecurityConfig(securityConfig) {
193
+ if (!securityConfig || typeof securityConfig !== 'object') {
194
+ return DEFAULT_SECURITY_CONFIG;
195
+ }
196
+
197
+ return {
198
+ ...securityConfig,
199
+ validateBuiltins: false,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * @typedef {'stable' | 'advanced' | 'unavailable'} CalculationBuiltinStatus
205
+ *
206
+ * @typedef {Object} CalculationBuiltinDefinition
207
+ * @property {string} name
208
+ * @property {string} category
209
+ * @property {string} signature
210
+ * @property {string} description
211
+ * @property {string[]} examples
212
+ * @property {CalculationBuiltinStatus} status
213
+ *
214
+ * @typedef {Object} CalculationReferenceDefinition
215
+ * @property {string} key
216
+ * @property {string} reference
217
+ * @property {string} dataName
218
+ * @property {string} label
219
+ * @property {string} type
220
+ * @property {string | null} description
221
+ * @property {string | null} displayStyle
222
+ * @property {string[]} parentPath
223
+ *
224
+ * @typedef {Object} CalculationExpressionIssue
225
+ * @property {string} code
226
+ * @property {'error' | 'warning'} severity
227
+ * @property {string} message
228
+ * @property {string | null} symbol
229
+ * @property {number | null} startIndex
230
+ * @property {number | null} endIndex
231
+ */
232
+
233
+ /**
234
+ * Return the canonical builtin catalog for CalculatedField authoring.
235
+ * Event-only builtins are intentionally excluded.
236
+ *
237
+ * @returns {CalculationBuiltinDefinition[]}
238
+ */
239
+ export function getCalculationBuiltinCatalog() {
240
+ return CALCULATION_BUILTIN_CATALOG.map(cloneCatalogDefinition);
241
+ }
242
+
243
+ /**
244
+ * Return the list of field references accessible from the given CalculatedField.
245
+ *
246
+ * @param {Object} options
247
+ * @param {Object} options.schema
248
+ * @param {string} options.fieldDataName
249
+ * @returns {CalculationReferenceDefinition[]}
250
+ */
251
+ export function getCalculationReferenceCatalog({ schema, fieldDataName }) {
252
+ const form = normalizeFormSchema(schema);
253
+ const resolver = new ContextResolver(form);
254
+ const executionContext = buildExecutionContext(form, fieldDataName, resolver);
255
+
256
+ return flattenFields(form.elements)
257
+ .filter((field) => field && typeof field === 'object')
258
+ .filter((field) => field.data_name && field.data_name !== fieldDataName)
259
+ .filter(
260
+ (field) =>
261
+ field.type !== 'Section' &&
262
+ field.type !== 'RepeatableSection' &&
263
+ field.type !== 'BuildingPlanSection'
264
+ )
265
+ .filter(
266
+ (field) => resolver.resolveFieldAccess(executionContext, field.data_name) === 'accessible'
267
+ )
268
+ .map((field) => ({
269
+ key: typeof field.key === 'string' ? field.key : field.data_name,
270
+ reference: `$${field.data_name}`,
271
+ dataName: field.data_name,
272
+ label: typeof field.label === 'string' ? field.label : field.data_name,
273
+ type: typeof field.type === 'string' ? field.type : 'UnknownField',
274
+ description: typeof field.description === 'string' ? field.description : null,
275
+ displayStyle:
276
+ field.display &&
277
+ typeof field.display === 'object' &&
278
+ typeof field.display.style === 'string'
279
+ ? field.display.style
280
+ : typeof field.display === 'string'
281
+ ? field.display
282
+ : null,
283
+ parentPath: resolver.getFieldInfo(field.data_name)?.parentPath || [],
284
+ }));
285
+ }
286
+
287
+ /**
288
+ * Statistically analyze a CalculatedField expression without executing it.
289
+ *
290
+ * @param {Object} options
291
+ * @param {string} options.expression
292
+ * @param {Object} options.schema
293
+ * @param {string} options.fieldDataName
294
+ * @param {Object} [options.securityConfig]
295
+ * @returns {{
296
+ * valid: boolean,
297
+ * issues: CalculationExpressionIssue[],
298
+ * usedBuiltins: string[],
299
+ * referencedFields: string[],
300
+ * }}
301
+ */
302
+ export function analyzeCalculationExpression({
303
+ expression,
304
+ schema,
305
+ fieldDataName,
306
+ securityConfig = DEFAULT_SECURITY_CONFIG,
307
+ }) {
308
+ const issues = [];
309
+ const issueKeys = new Set();
310
+ const usedBuiltins = [];
311
+ const usedBuiltinNames = new Set();
312
+ const referencedFields = [];
313
+ const referencedFieldNames = new Set();
314
+
315
+ const normalizedExpression = typeof expression === 'string' ? expression : '';
316
+ const form = normalizeFormSchema(schema);
317
+ const resolver = new ContextResolver(form);
318
+ const executionContext = buildExecutionContext(form, fieldDataName, resolver);
319
+
320
+ if (normalizedExpression.trim().length === 0) {
321
+ addIssue(
322
+ issues,
323
+ issueKeys,
324
+ createIssue({
325
+ code: 'empty_expression',
326
+ severity: 'error',
327
+ message: 'Calculation expression cannot be empty.',
328
+ index: 0,
329
+ length: 1,
330
+ })
331
+ );
332
+ }
333
+
334
+ try {
335
+ validateSyntax(normalizedExpression);
336
+ } catch (error) {
337
+ addIssue(
338
+ issues,
339
+ issueKeys,
340
+ createIssue({
341
+ code: 'invalid_syntax',
342
+ severity: 'error',
343
+ message:
344
+ error instanceof Error && error.message
345
+ ? `Invalid calculation syntax: ${error.message}`
346
+ : 'Invalid calculation syntax.',
347
+ index: 0,
348
+ length: 1,
349
+ })
350
+ );
351
+ }
352
+
353
+ const securityValidation = validateExpression(
354
+ normalizedExpression,
355
+ createSchemaFilteredSecurityConfig(securityConfig),
356
+ false
357
+ );
358
+
359
+ if (!securityValidation.valid) {
360
+ addIssue(
361
+ issues,
362
+ issueKeys,
363
+ createIssue({
364
+ code: 'security_validation_failed',
365
+ severity: 'error',
366
+ message: securityValidation.reason,
367
+ index: 0,
368
+ length: 1,
369
+ })
370
+ );
371
+ }
372
+
373
+ for (const call of extractFunctionCalls(normalizedExpression)) {
374
+ if (isBuiltinCallMatchAllowed(call.name)) {
375
+ continue;
376
+ }
377
+
378
+ const builtinDefinition = BUILTIN_DEFINITION_BY_NAME.get(call.name);
379
+ if (builtinDefinition) {
380
+ const isCalculationBuiltin = builtinDefinition.contexts.includes(
381
+ BUILTIN_CONTEXTS.CALCULATION
382
+ );
383
+
384
+ if (!isCalculationBuiltin && builtinDefinition.contexts.includes(BUILTIN_CONTEXTS.EVENT)) {
385
+ addIssue(
386
+ issues,
387
+ issueKeys,
388
+ createIssue({
389
+ code: 'event_builtin_not_allowed',
390
+ severity: 'error',
391
+ message: `${call.name}() is only available in form event handlers, not in CalculatedField expressions.`,
392
+ symbol: call.name,
393
+ index: call.index,
394
+ length: call.length,
395
+ })
396
+ );
397
+ continue;
398
+ }
399
+
400
+ const builtinMetadata = CALCULATION_BUILTIN_METADATA_BY_NAME.get(call.name);
401
+ if (!builtinMetadata) {
402
+ continue;
403
+ }
404
+
405
+ if (!usedBuiltinNames.has(call.name)) {
406
+ usedBuiltinNames.add(call.name);
407
+ usedBuiltins.push(call.name);
408
+ }
409
+
410
+ if (builtinMetadata.status === 'unavailable') {
411
+ addIssue(
412
+ issues,
413
+ issueKeys,
414
+ createIssue({
415
+ code: 'unavailable_builtin',
416
+ severity: 'error',
417
+ message: `${call.name}() is not available in CalculatedField expressions.`,
418
+ symbol: call.name,
419
+ index: call.index,
420
+ length: call.length,
421
+ })
422
+ );
423
+ } else if (builtinMetadata.status === 'advanced') {
424
+ addIssue(
425
+ issues,
426
+ issueKeys,
427
+ createIssue({
428
+ code: 'advanced_builtin',
429
+ severity: 'warning',
430
+ message: `${call.name}() is an advanced builtin. Prefer direct $field references when possible.`,
431
+ symbol: call.name,
432
+ index: call.index,
433
+ length: call.length,
434
+ })
435
+ );
436
+ }
437
+
438
+ continue;
439
+ }
440
+
441
+ addIssue(
442
+ issues,
443
+ issueKeys,
444
+ createIssue({
445
+ code: 'unknown_builtin',
446
+ severity: 'error',
447
+ message: `Unknown builtin function: ${call.name}.`,
448
+ symbol: call.name,
449
+ index: call.index,
450
+ length: call.length,
451
+ })
452
+ );
453
+ }
454
+
455
+ for (const reference of extractFieldReferenceMatches(normalizedExpression)) {
456
+ if (!referencedFieldNames.has(reference.fieldName)) {
457
+ referencedFieldNames.add(reference.fieldName);
458
+ referencedFields.push(reference.fieldName);
459
+ }
460
+
461
+ const accessLevel = resolver.resolveFieldAccess(executionContext, reference.fieldName);
462
+ if (accessLevel === 'restricted') {
463
+ addIssue(
464
+ issues,
465
+ issueKeys,
466
+ createIssue({
467
+ code: 'restricted_field_reference',
468
+ severity: 'error',
469
+ message: resolver.generateAccessSuggestion(
470
+ executionContext,
471
+ reference.fieldName,
472
+ resolver.getFieldInfo(reference.fieldName)
473
+ ),
474
+ symbol: reference.reference,
475
+ index: reference.index,
476
+ length: reference.length,
477
+ })
478
+ );
479
+ } else if (accessLevel === 'not_found') {
480
+ addIssue(
481
+ issues,
482
+ issueKeys,
483
+ createIssue({
484
+ code: 'unknown_field_reference',
485
+ severity: 'error',
486
+ message: `Field '${reference.fieldName}' does not exist in the form schema.`,
487
+ symbol: reference.reference,
488
+ index: reference.index,
489
+ length: reference.length,
490
+ })
491
+ );
492
+ }
493
+ }
494
+
495
+ return {
496
+ valid: !issues.some((issue) => issue.severity === 'error'),
497
+ issues,
498
+ usedBuiltins,
499
+ referencedFields,
500
+ };
501
+ }
@@ -199,6 +199,12 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
199
199
  sectionFields,
200
200
  } = buildRepeatableMetadata(elementsSource);
201
201
 
202
+ const hasStructuredRepeatableState =
203
+ state &&
204
+ Object.prototype.hasOwnProperty.call(state, 'repeatable') &&
205
+ state.repeatable &&
206
+ typeof state.repeatable === 'object';
207
+
202
208
  // Transform the values object using the preferred keys
203
209
  for (const [dataName, value] of Object.entries(state.values)) {
204
210
  // Skip flattened choice fields (created by form renderer for allow_other fields)
@@ -292,7 +298,9 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
292
298
  const childIds = getNestedChildIds([...currentPath, pathKey]);
293
299
 
294
300
  // Structured RepeatableSection state supplied (new format)
295
- if (Array.isArray(structuredInstances) && structuredInstances.length > 0) {
301
+ // Empty arrays are treated as explicit "no instances" and must not fall back
302
+ // to legacy flattened behavior, which can emit phantom records.
303
+ if (Array.isArray(structuredInstances)) {
296
304
  const recordCount = Math.max(structuredInstances.length, childIds.length);
297
305
  const childRecords = [];
298
306
 
@@ -360,16 +368,20 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
360
368
  }
361
369
 
362
370
  for (const [, childRepInfo] of repInfo.children) {
363
- const nestedInstances = instanceRepeatable[childRepInfo.preferredKey] || [];
371
+ const rawNestedInstances = Object.prototype.hasOwnProperty.call(
372
+ instanceRepeatable,
373
+ childRepInfo.preferredKey
374
+ )
375
+ ? instanceRepeatable[childRepInfo.preferredKey]
376
+ : [];
377
+ const nestedInstances = Array.isArray(rawNestedInstances) ? rawNestedInstances : [];
364
378
  const childRepeatableArray = processRepeatableSection(
365
379
  childRepInfo,
366
380
  [...currentPath, pathKey],
367
381
  nestedInstances
368
382
  );
369
- if (childRepeatableArray.length > 0) {
370
- const childOutputKey = resolveRepeatableOutputKey(childRepInfo);
371
- childRecord.form_values[childOutputKey] = childRepeatableArray;
372
- }
383
+ const childOutputKey = resolveRepeatableOutputKey(childRepInfo);
384
+ childRecord.form_values[childOutputKey] = childRepeatableArray;
373
385
  }
374
386
 
375
387
  if (hasMeaningfulRecord(childRecord)) {
@@ -501,14 +513,23 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
501
513
  for (const [, repInfo] of repeatableSectionTree) {
502
514
  if (repInfo.parentPath.length === 0) {
503
515
  // This is a top-level RepeatableSection
504
- const structuredInstances =
505
- state.repeatable && state.repeatable[repInfo.preferredKey]
516
+ const rawStructuredInstances = hasStructuredRepeatableState
517
+ ? Object.prototype.hasOwnProperty.call(state.repeatable, repInfo.preferredKey)
506
518
  ? state.repeatable[repInfo.preferredKey]
507
- : null;
519
+ : []
520
+ : null;
521
+ const structuredInstances = hasStructuredRepeatableState
522
+ ? Array.isArray(rawStructuredInstances)
523
+ ? rawStructuredInstances
524
+ : []
525
+ : null;
508
526
 
509
527
  const childRecords = processRepeatableSection(repInfo, [], structuredInstances);
510
- if (childRecords.length > 0) {
511
- const repeatableOutputKey = resolveRepeatableOutputKey(repInfo);
528
+ const repeatableOutputKey = resolveRepeatableOutputKey(repInfo);
529
+ if (hasStructuredRepeatableState) {
530
+ // In structured mode keep explicit repeatable keys, even when empty.
531
+ form_values[repeatableOutputKey] = childRecords;
532
+ } else if (childRecords.length > 0) {
512
533
  form_values[repeatableOutputKey] = childRecords;
513
534
  }
514
535
  }