form0-core 0.1.4 → 0.1.6

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.4",
3
+ "version": "0.1.6",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
@@ -9,7 +9,8 @@ export function evaluateCalculatedFields(
9
9
  helpers,
10
10
  securityConfig,
11
11
  contextResolver = null,
12
- warningSystem = null
12
+ warningSystem = null,
13
+ runtimeDiagnostics = null
13
14
  ) {
14
15
  const fields = flattenFields(schema.elements);
15
16
 
@@ -40,10 +41,37 @@ export function evaluateCalculatedFields(
40
41
  context,
41
42
  securityConfig,
42
43
  false,
43
- schema
44
+ schema,
45
+ {
46
+ suppressConsoleWarning: Array.isArray(runtimeDiagnostics),
47
+ onError: (error) => {
48
+ if (!Array.isArray(runtimeDiagnostics)) {
49
+ return;
50
+ }
51
+
52
+ runtimeDiagnostics.push({
53
+ fieldName: field.data_name,
54
+ message:
55
+ error instanceof Error && error.message
56
+ ? error.message
57
+ : 'Unknown calculation runtime error.',
58
+ });
59
+ },
60
+ }
44
61
  );
45
62
  } catch (e) {
46
- console.warn(`Calculation failed for ${field.data_name}:`, e.message);
63
+ if (Array.isArray(runtimeDiagnostics)) {
64
+ runtimeDiagnostics.push({
65
+ fieldName: field.data_name,
66
+ message:
67
+ e instanceof Error && e.message
68
+ ? e.message
69
+ : 'Unknown calculation runtime error.',
70
+ });
71
+ }
72
+ if (!Array.isArray(runtimeDiagnostics)) {
73
+ console.warn(`Calculation failed for ${field.data_name}:`, e.message);
74
+ }
47
75
  }
48
76
  }
49
77
  });
@@ -93,7 +93,13 @@ export class ContextResolver {
93
93
 
94
94
  // Main form fields (no parent RepeatableSection) are always accessible
95
95
  if (fieldInfo.parentPath.length === 0) {
96
- return this.buildAccessInfo('accessible', 'main_form', fieldName, fieldInfo, executionContext);
96
+ return this.buildAccessInfo(
97
+ 'accessible',
98
+ 'main_form',
99
+ fieldName,
100
+ fieldInfo,
101
+ executionContext
102
+ );
97
103
  }
98
104
 
99
105
  // RepeatableSection field access depends on execution context
@@ -246,7 +252,10 @@ export class ContextResolver {
246
252
  * @returns {boolean} True if they share the same RepeatableSection context
247
253
  */
248
254
  haveSameRepeatableSectionContext(parentPath1, parentPath2) {
249
- return this.resolveRepeatableSectionAccessCode(parentPath1, parentPath2) !== 'different_repeatable_section';
255
+ return (
256
+ this.resolveRepeatableSectionAccessCode(parentPath1, parentPath2) !==
257
+ 'different_repeatable_section'
258
+ );
250
259
  }
251
260
 
252
261
  resolveRepeatableSectionRelationship(
@@ -271,7 +280,10 @@ export class ContextResolver {
271
280
  return contextPath.length === 0 ? 'main_form' : 'same_repeatable_section';
272
281
  }
273
282
 
274
- if (targetPath.length < contextPath.length && ContextResolver.isPrefix(targetPath, contextPath)) {
283
+ if (
284
+ targetPath.length < contextPath.length &&
285
+ ContextResolver.isPrefix(targetPath, contextPath)
286
+ ) {
275
287
  return targetPath.length === 0 ? 'main_form' : 'ancestor_repeatable_context';
276
288
  }
277
289
 
@@ -293,7 +305,12 @@ export class ContextResolver {
293
305
  level,
294
306
  code,
295
307
  message: `Field '${fieldName}' is not accessible from current context`,
296
- suggestion: this.buildRestrictedAccessSuggestion(executionContext, fieldName, fieldInfo, code),
308
+ suggestion: this.buildRestrictedAccessSuggestion(
309
+ executionContext,
310
+ fieldName,
311
+ fieldInfo,
312
+ code
313
+ ),
297
314
  fieldInfo,
298
315
  };
299
316
  }
@@ -303,7 +320,7 @@ export class ContextResolver {
303
320
 
304
321
  if (contextType === 'calculation') {
305
322
  if (code === 'different_repeatable_section') {
306
- return `CalculatedField can only access fields in the same RepeatableSection or ancestor contexts. Field '${fieldName}' is in RepeatableSection: ${fieldInfo.parentPath.join(' -> ')}`;
323
+ return `CalculatedField can only access fields in the same RepeatableSection or ancestor contexts. Field '${fieldName}' is in RepeatableSection: ${this.formatRepeatableSectionPath(fieldInfo.parentPath)}`;
307
324
  }
308
325
  }
309
326
 
@@ -322,6 +339,33 @@ export class ContextResolver {
322
339
  return 'Check the field access rules for your current context';
323
340
  }
324
341
 
342
+ formatRepeatableSectionPath(parentPath) {
343
+ if (!Array.isArray(parentPath) || parentPath.length === 0) {
344
+ return 'main form';
345
+ }
346
+
347
+ return parentPath
348
+ .map((_, index) => this.formatRepeatableSectionSegment(parentPath.slice(0, index + 1)))
349
+ .join(' -> ');
350
+ }
351
+
352
+ formatRepeatableSectionSegment(currentPath) {
353
+ for (const treeInfo of this.repeatableSectionTree.values()) {
354
+ if (!ContextResolver.pathsEqual(treeInfo.currentPath, currentPath)) {
355
+ continue;
356
+ }
357
+
358
+ const dataName = treeInfo.field?.data_name || treeInfo.preferredKey;
359
+ if (dataName === treeInfo.preferredKey) {
360
+ return dataName;
361
+ }
362
+
363
+ return `${dataName} (${treeInfo.preferredKey})`;
364
+ }
365
+
366
+ return currentPath[currentPath.length - 1];
367
+ }
368
+
325
369
  static isPrefix(prefix, fullPath) {
326
370
  if (prefix.length === 0) {
327
371
  return true;
@@ -381,7 +425,8 @@ export class ContextResolver {
381
425
  ? `Field '${fieldName}' does not exist in the form schema`
382
426
  : `Field '${fieldName}' is not accessible from current context`),
383
427
  suggestion:
384
- accessInfo.suggestion || this.generateAccessSuggestion(executionContext, fieldName, fieldInfo),
428
+ accessInfo.suggestion ||
429
+ this.generateAccessSuggestion(executionContext, fieldName, fieldInfo),
385
430
  fieldContext: fieldInfo
386
431
  ? {
387
432
  parentPath: fieldInfo.parentPath,
@@ -3,19 +3,26 @@ import { __setEvalContext, __clearEvalContext } from '../builtins/control/eval.j
3
3
  import { __setDataNamesContext, __clearDataNamesContext } from '../builtins/schema/datanames.js';
4
4
  import { validateExpression, createSecureContext, withTimeout } from '../security/validation.js';
5
5
  import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
6
+ import {
7
+ isMultilineCalculationExpression,
8
+ normalizeInlineCalculationExpression,
9
+ } from '../utilities/calculation-expression-utils.js';
6
10
 
7
11
  export function runExpression(
8
12
  expr,
9
13
  context = {},
10
14
  securityConfig = DEFAULT_SECURITY_CONFIG,
11
15
  includeEventBuiltins = false,
12
- schema = null
16
+ schema = null,
17
+ options = {}
13
18
  ) {
14
19
  try {
15
20
  // Validate expression based on security mode
16
21
  const validation = validateExpression(expr, securityConfig, includeEventBuiltins);
17
22
  if (!validation.valid) {
18
- console.warn('[form0] Expression validation failed:', validation.reason);
23
+ if (!options.suppressConsoleWarning) {
24
+ console.warn('[form0] Expression validation failed:', validation.reason);
25
+ }
19
26
  return null;
20
27
  }
21
28
 
@@ -34,8 +41,7 @@ export function runExpression(
34
41
 
35
42
  try {
36
43
  // Handle both expressions and multi-line code (Windows-safe)
37
- const isMultiLine =
38
- expr.includes('\r\n') || expr.includes('\n') || expr.includes('function');
44
+ const isMultiLine = isMultilineCalculationExpression(expr);
39
45
 
40
46
  if (isMultiLine) {
41
47
  // Execute as code block (recompile each time for now)
@@ -46,7 +52,8 @@ export function runExpression(
46
52
  return consumed.called ? consumed.value : result;
47
53
  } else {
48
54
  // Execute as expression (existing behavior)
49
- const fn = new Function(...keys, `return (${expr});`);
55
+ const inlineExpression = normalizeInlineCalculationExpression(expr);
56
+ const fn = new Function(...keys, `return (${inlineExpression});`);
50
57
  const result = fn(...values);
51
58
  const consumed = __consumeResult();
52
59
  return consumed.called ? consumed.value : result;
@@ -69,7 +76,12 @@ export function runExpression(
69
76
 
70
77
  return executeExpression();
71
78
  } catch (e) {
72
- console.warn('[form0] Expression evaluation failed:', e.message);
79
+ if (typeof options.onError === 'function') {
80
+ options.onError(e);
81
+ }
82
+ if (!options.suppressConsoleWarning) {
83
+ console.warn('[form0] Expression evaluation failed:', e.message);
84
+ }
73
85
  return null;
74
86
  }
75
87
  }
@@ -19,6 +19,7 @@ export function createFormEngine({
19
19
  helpers = {},
20
20
  security = DEFAULT_SECURITY_CONFIG,
21
21
  warningSystem = null,
22
+ runtimeDiagnostics = null,
22
23
  }) {
23
24
  const { schema: preparedSchema, buildingPlanMeta } = expandBuildingPlanSchema(schema);
24
25
 
@@ -126,7 +127,8 @@ export function createFormEngine({
126
127
  allHelpers,
127
128
  security,
128
129
  contextResolver,
129
- sharedWarningSystem
130
+ sharedWarningSystem,
131
+ runtimeDiagnostics
130
132
  );
131
133
  evaluateRequirement(form, values, required);
132
134
  evaluateVisibility(form, values, visible);
package/src/index.js CHANGED
@@ -35,6 +35,7 @@ export {
35
35
  getCalculationBuiltinCatalog,
36
36
  getCalculationReferenceCatalog,
37
37
  analyzeCalculationExpression,
38
+ simulateCalculationExpression,
38
39
  } from './utilities/calculation-editor.js';
39
40
  export {
40
41
  getFormAIMetadata,
@@ -4,8 +4,14 @@ import {
4
4
  CALCULATION_BUILTIN_DEFINITIONS,
5
5
  } from '../builtins/registry.js';
6
6
  import { ContextResolver } from '../engine/context-resolver.js';
7
+ import { createFormEngine } from '../engine/form-engine.js';
8
+ import { WarningSystem } from '../engine/warning-system.js';
7
9
  import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
8
10
  import { validateExpression } from '../security/validation.js';
11
+ import {
12
+ isMultilineCalculationExpression,
13
+ normalizeInlineCalculationExpression,
14
+ } from './calculation-expression-utils.js';
9
15
  import { flattenFields } from './field-helpers.js';
10
16
 
11
17
  const COMMON_GLOBAL_FUNCTIONS = new Set([
@@ -181,9 +187,7 @@ function toCalculationReferenceDefinition(field, resolver, executionContext) {
181
187
  type: typeof field.type === 'string' ? field.type : 'UnknownField',
182
188
  description: typeof field.description === 'string' ? field.description : null,
183
189
  displayStyle:
184
- field.display &&
185
- typeof field.display === 'object' &&
186
- typeof field.display.style === 'string'
190
+ field.display && typeof field.display === 'object' && typeof field.display.style === 'string'
187
191
  ? field.display.style
188
192
  : typeof field.display === 'string'
189
193
  ? field.display
@@ -214,15 +218,15 @@ function addIssue(issues, issueKeys, issue) {
214
218
  }
215
219
 
216
220
  function validateSyntax(expression) {
217
- const isMultiLine =
218
- expression.includes('\r\n') || expression.includes('\n') || expression.includes('function');
221
+ const isMultiLine = isMultilineCalculationExpression(expression);
219
222
 
220
223
  if (isMultiLine) {
221
224
  new Function(expression);
222
225
  return;
223
226
  }
224
227
 
225
- new Function(`return (${expression});`);
228
+ const inlineExpression = normalizeInlineCalculationExpression(expression);
229
+ new Function(`return (${inlineExpression});`);
226
230
  }
227
231
 
228
232
  function createSchemaFilteredSecurityConfig(securityConfig) {
@@ -236,6 +240,62 @@ function createSchemaFilteredSecurityConfig(securityConfig) {
236
240
  };
237
241
  }
238
242
 
243
+ function deepClone(value) {
244
+ if (typeof structuredClone === 'function') {
245
+ return structuredClone(value);
246
+ }
247
+
248
+ return JSON.parse(JSON.stringify(value));
249
+ }
250
+
251
+ function cloneSimulationSchema(schema) {
252
+ if (schema && schema.form && Array.isArray(schema.form.elements)) {
253
+ return deepClone(schema);
254
+ }
255
+
256
+ return {
257
+ form: deepClone(normalizeFormSchema(schema)),
258
+ };
259
+ }
260
+
261
+ function findFieldByDataName(elements, fieldDataName) {
262
+ if (!Array.isArray(elements)) {
263
+ return null;
264
+ }
265
+
266
+ for (const element of elements) {
267
+ if (!element || typeof element !== 'object') {
268
+ continue;
269
+ }
270
+
271
+ if (element.data_name === fieldDataName) {
272
+ return element;
273
+ }
274
+
275
+ if (Array.isArray(element.elements)) {
276
+ const nestedMatch = findFieldByDataName(element.elements, fieldDataName);
277
+ if (nestedMatch) {
278
+ return nestedMatch;
279
+ }
280
+ }
281
+ }
282
+
283
+ return null;
284
+ }
285
+
286
+ function sanitizeCollectedWarnings(warnings) {
287
+ if (!Array.isArray(warnings)) {
288
+ return [];
289
+ }
290
+
291
+ return warnings.map((warning) => ({
292
+ message: warning?.message || '',
293
+ suggestion: warning?.suggestion || null,
294
+ context: warning?.context || null,
295
+ fieldContext: warning?.fieldContext || null,
296
+ }));
297
+ }
298
+
239
299
  /**
240
300
  * @typedef {'stable' | 'advanced' | 'unavailable'} CalculationBuiltinStatus
241
301
  *
@@ -341,6 +401,7 @@ export function analyzeCalculationExpression({
341
401
  const usedBuiltinNames = new Set();
342
402
  const referencedFields = [];
343
403
  const referencedFieldNames = new Set();
404
+ const setResultCalls = [];
344
405
 
345
406
  const normalizedExpression = typeof expression === 'string' ? expression : '';
346
407
  const form = normalizeFormSchema(schema);
@@ -437,6 +498,10 @@ export function analyzeCalculationExpression({
437
498
  usedBuiltins.push(call.name);
438
499
  }
439
500
 
501
+ if (call.name === 'SETRESULT') {
502
+ setResultCalls.push(call);
503
+ }
504
+
440
505
  if (builtinMetadata.status === 'unavailable') {
441
506
  addIssue(
442
507
  issues,
@@ -482,6 +547,22 @@ export function analyzeCalculationExpression({
482
547
  );
483
548
  }
484
549
 
550
+ if (setResultCalls.length > 1) {
551
+ const duplicateSetResultCall = setResultCalls[1];
552
+ addIssue(
553
+ issues,
554
+ issueKeys,
555
+ createIssue({
556
+ code: 'multiple_setresult_calls',
557
+ severity: 'error',
558
+ message: 'SETRESULT() can only be used once in a CalculatedField expression.',
559
+ symbol: duplicateSetResultCall.name,
560
+ index: duplicateSetResultCall.index,
561
+ length: duplicateSetResultCall.length,
562
+ })
563
+ );
564
+ }
565
+
485
566
  for (const reference of extractFieldReferenceMatches(normalizedExpression)) {
486
567
  if (!referencedFieldNames.has(reference.fieldName)) {
487
568
  referencedFieldNames.add(reference.fieldName);
@@ -532,3 +613,94 @@ export function analyzeCalculationExpression({
532
613
  referencedFields,
533
614
  };
534
615
  }
616
+
617
+ /**
618
+ * Simulate a CalculatedField expression against form0-core's real evaluation engine.
619
+ *
620
+ * @param {Object} options
621
+ * @param {Object} options.schema
622
+ * @param {string} options.fieldDataName
623
+ * @param {string} options.expression
624
+ * @param {Object} [options.values]
625
+ * @param {Object} [options.security]
626
+ * @returns {{
627
+ * result: unknown,
628
+ * runtimeError: string | null,
629
+ * warnings: Array<{
630
+ * message: string,
631
+ * suggestion: string | null,
632
+ * context: Object | null,
633
+ * fieldContext: Object | null,
634
+ * }>,
635
+ * }}
636
+ */
637
+ export function simulateCalculationExpression({
638
+ schema,
639
+ fieldDataName,
640
+ expression,
641
+ values = {},
642
+ security = DEFAULT_SECURITY_CONFIG,
643
+ }) {
644
+ const simulationSchema = cloneSimulationSchema(schema);
645
+ const form = normalizeFormSchema(simulationSchema);
646
+ const targetField = findFieldByDataName(form.elements, fieldDataName);
647
+
648
+ if (!targetField) {
649
+ return {
650
+ result: null,
651
+ runtimeError: `Field '${fieldDataName}' does not exist in the form schema.`,
652
+ warnings: [],
653
+ };
654
+ }
655
+
656
+ if (targetField.type !== 'CalculatedField') {
657
+ return {
658
+ result: null,
659
+ runtimeError: `Field '${fieldDataName}' is not a CalculatedField.`,
660
+ warnings: [],
661
+ };
662
+ }
663
+
664
+ targetField.calculate = typeof expression === 'string' ? expression : '';
665
+
666
+ const warningSystem = new WarningSystem({
667
+ enableCollection: true,
668
+ enableConsoleWarnings: false,
669
+ });
670
+ const runtimeDiagnostics = [];
671
+
672
+ try {
673
+ const engine = createFormEngine({
674
+ schema: simulationSchema,
675
+ initialValues:
676
+ values && typeof values === 'object' && !Array.isArray(values) ? values : {},
677
+ security,
678
+ warningSystem,
679
+ runtimeDiagnostics,
680
+ });
681
+
682
+ engine.eval();
683
+
684
+ const engineState = engine.getState();
685
+ const simulatedResult = engineState.values[fieldDataName];
686
+ const runtimeError =
687
+ runtimeDiagnostics.find((diagnostic) => diagnostic.fieldName === fieldDataName)?.message ||
688
+ runtimeDiagnostics[0]?.message ||
689
+ null;
690
+
691
+ return {
692
+ result: simulatedResult === undefined ? null : simulatedResult,
693
+ runtimeError,
694
+ warnings: sanitizeCollectedWarnings(warningSystem.getCollectedWarnings()),
695
+ };
696
+ } catch (error) {
697
+ return {
698
+ result: null,
699
+ runtimeError:
700
+ error instanceof Error && error.message
701
+ ? error.message
702
+ : 'Unknown calculation simulation error.',
703
+ warnings: sanitizeCollectedWarnings(warningSystem.getCollectedWarnings()),
704
+ };
705
+ }
706
+ }
@@ -0,0 +1,15 @@
1
+ export function isMultilineCalculationExpression(expression) {
2
+ const normalizedExpression = typeof expression === 'string' ? expression : '';
3
+
4
+ return (
5
+ normalizedExpression.includes('\r\n') ||
6
+ normalizedExpression.includes('\n') ||
7
+ normalizedExpression.includes('function')
8
+ );
9
+ }
10
+
11
+ export function normalizeInlineCalculationExpression(expression) {
12
+ const normalizedExpression = typeof expression === 'string' ? expression : '';
13
+
14
+ return normalizedExpression.replace(/;+\s*$/u, '');
15
+ }