form0-core 0.5.1 → 0.7.0

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,125 @@
1
+ /**
2
+ * @typedef {Object} SubmissionFinding
3
+ * @property {'required'|'error'|'custom'} kind
4
+ * @property {string} message
5
+ * @property {Object[]} instancePath Complete row ancestry; [] for root.
6
+ * @property {string} [fieldName] Absent for scope-level custom findings.
7
+ * @property {string} [fieldKey]
8
+ *
9
+ * @typedef {Object} SubmissionValidationSummary
10
+ * @property {boolean} hasErrors
11
+ * @property {SubmissionFinding[]} requiredFieldErrors
12
+ * @property {SubmissionFinding[]} generalErrors
13
+ * @property {SubmissionFinding[]} scopeIssues
14
+ * @property {SubmissionFinding[]} orderedIssues
15
+ */
16
+
17
+ import { prepareRecordContext, visitRecordScopes } from './record-context.js';
18
+
19
+ const hasText = (value) => typeof value === 'string' && value.trim().length > 0;
20
+ const hasChoice = (entries) =>
21
+ Array.isArray(entries) &&
22
+ entries.some(
23
+ (entry) => entry && ((entry.value != null && entry.value !== '') || hasText(entry.label))
24
+ );
25
+ const hasOther = (entries) =>
26
+ Array.isArray(entries) &&
27
+ entries.some((entry) =>
28
+ typeof entry === 'string'
29
+ ? hasText(entry)
30
+ : entry && (hasText(entry.label) || (entry.value != null && entry.value !== ''))
31
+ );
32
+
33
+ /** Type-aware input emptiness shared with submission participation; zero/false are values. */
34
+ export function isFieldValueEmpty(field, value) {
35
+ if (!field || value == null) return true;
36
+ switch (field.type) {
37
+ case 'TextField':
38
+ case 'CalculatedField':
39
+ case 'DateField':
40
+ case 'TimeField':
41
+ case 'TitleField':
42
+ case 'StatusField':
43
+ return typeof value === 'string' && !value.trim();
44
+ case 'NumericField':
45
+ return typeof value === 'number' ? false : typeof value === 'string' ? !value.trim() : true;
46
+ case 'SingleChoiceField':
47
+ case 'BooleanField':
48
+ return !hasChoice(value.choice) && !hasOther(value.other);
49
+ case 'MultiChoiceField':
50
+ return !hasChoice(value.choices) && !hasOther(value.other);
51
+ case 'PhotoField':
52
+ case 'VideoField':
53
+ return !Array.isArray(value) || value.length === 0;
54
+ case 'SignatureField':
55
+ return typeof value === 'string'
56
+ ? !value.trim()
57
+ : !(hasText(value?.data) || hasText(value?.url));
58
+ default:
59
+ return Array.isArray(value)
60
+ ? value.length === 0
61
+ : typeof value === 'object'
62
+ ? Object.keys(value).length === 0
63
+ : false;
64
+ }
65
+ }
66
+
67
+ export function validateScope(scope) {
68
+ const findings = [];
69
+ if (!scope.participating) return findings;
70
+ const { record, template, instancePath } = scope;
71
+ for (const field of template.fields) {
72
+ if (['Section', 'BuildingPlanSection', 'RepeatableSection', 'LabelField'].includes(field.type))
73
+ continue;
74
+ if (
75
+ record.visible?.[field.data_name] === false ||
76
+ !(template.chains.get(field.data_name) || []).every(
77
+ (name) => record.visible?.[name] !== false
78
+ )
79
+ )
80
+ continue;
81
+ const message = record.errors?.[field.data_name];
82
+ const required = record.required?.[field.data_name] ?? field.required;
83
+ const base = {
84
+ fieldName: field.data_name,
85
+ fieldKey: field.key || field.data_name,
86
+ instancePath,
87
+ };
88
+ if (message) findings.push({ ...base, kind: 'error', message });
89
+ else if (required && isFieldValueEmpty(field, record.values?.[field.data_name]))
90
+ findings.push({ ...base, kind: 'required', message: 'This field is required' });
91
+ }
92
+ return findings;
93
+ }
94
+
95
+ export function validateSubmissionWithContext(snapshot, context, customIssues = []) {
96
+ const orderedIssues = [];
97
+ const issuesByScope = new Map();
98
+ for (const custom of customIssues) {
99
+ const key = JSON.stringify(custom.instancePath || []);
100
+ if (!issuesByScope.has(key)) issuesByScope.set(key, []);
101
+ issuesByScope.get(key).push(custom);
102
+ }
103
+ visitRecordScopes(snapshot, context, (scope) => {
104
+ orderedIssues.push(...validateScope(scope));
105
+ if (scope.participating)
106
+ for (const custom of issuesByScope.get(JSON.stringify(scope.instancePath)) || [])
107
+ orderedIssues.push({ ...custom, instancePath: scope.instancePath, kind: 'custom' });
108
+ });
109
+ return {
110
+ hasErrors: orderedIssues.length > 0,
111
+ requiredFieldErrors: orderedIssues.filter((issue) => issue.kind === 'required'),
112
+ generalErrors: orderedIssues.filter((issue) => issue.kind === 'error'),
113
+ scopeIssues: orderedIssues.filter((issue) => issue.kind === 'custom'),
114
+ orderedIssues,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Validate an already evaluated runtime record tree; never evaluates or mutates intrinsic errors.
120
+ * @param {{schema: Object, snapshot: Object, validationIssues?: Object[]}} options
121
+ * @returns {SubmissionValidationSummary} Ordered field and scope findings; no freshness assertion.
122
+ */
123
+ export function validateSubmission({ schema, snapshot, validationIssues = [] }) {
124
+ return validateSubmissionWithContext(snapshot, prepareRecordContext(schema), validationIssues);
125
+ }
@@ -179,6 +179,10 @@ export class WarningSystem {
179
179
  return `CalculatedField '${fieldName}'`;
180
180
  }
181
181
 
182
+ if (type === 'condition') {
183
+ return `${executionContext.conditionProperty} on '${fieldName}'`;
184
+ }
185
+
182
186
  if (type === 'event') {
183
187
  if (fieldName) {
184
188
  return `Event '${eventType}' on field '${fieldName}'`;
package/src/index.js CHANGED
@@ -86,3 +86,6 @@ export {
86
86
  applyFormMutationBatch,
87
87
  } from './authoring/form-authoring.js';
88
88
  export { INVALID } from './builtins/event/control/invalid.js';
89
+
90
+ export { createRecordEvaluator } from './engine/record-evaluator.js';
91
+ export { validateSubmission, isFieldValueEmpty } from './engine/submission-validation.js';
@@ -1,4 +1,5 @@
1
1
  import { FIELD_TYPES } from '../utilities/field-types.js';
2
+ import { forEachConditionReference } from '../engine/condition-context.js';
2
3
 
3
4
  // Define which operators are valid for each field type
4
5
  const FIELD_TYPE_OPERATORS = {
@@ -188,26 +189,7 @@ export function validateFieldConditions(field, conditions, allFields = {}) {
188
189
  return { isValid: false, errors };
189
190
  }
190
191
 
191
- // Skip validation for container fields
192
- if (
193
- field.type === 'Section' ||
194
- field.type === 'LabelField' ||
195
- field.type === 'RepeatableSection'
196
- ) {
197
- return { isValid: true, errors: [] };
198
- }
199
-
200
192
  const validateCondition = (condition) => {
201
- if (condition.and) {
202
- condition.and.forEach(validateCondition);
203
- return;
204
- }
205
-
206
- if (condition.or) {
207
- condition.or.forEach(validateCondition);
208
- return;
209
- }
210
-
211
193
  // Use field_id (normalized to key by ensure-keys.js)
212
194
  if (condition.operator && condition.field_id) {
213
195
  // Find the target field being referenced
@@ -236,11 +218,7 @@ export function validateFieldConditions(field, conditions, allFields = {}) {
236
218
  }
237
219
  };
238
220
 
239
- if (Array.isArray(conditions)) {
240
- conditions.forEach(validateCondition);
241
- } else {
242
- validateCondition(conditions);
243
- }
221
+ forEachConditionReference(conditions, validateCondition);
244
222
 
245
223
  return { isValid: errors.length === 0, errors };
246
224
  }
@@ -4,6 +4,8 @@ import { isSupportedFieldType } from '../utilities/field-types.js';
4
4
  import { validateFieldConditions } from './operators.js';
5
5
  import { validateFormAIMetadata } from './ai-metadata-validator.js';
6
6
  import { buildDatasetDescriptors } from '../utilities/dataset-descriptors.js';
7
+ import { ContextResolver } from '../engine/context-resolver.js';
8
+ import { CONDITION_PROPERTIES, getConditionReferenceIssues } from '../engine/condition-context.js';
7
9
 
8
10
  const toNonEmptyString = (value) =>
9
11
  typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
@@ -72,12 +74,22 @@ const validateScopedTitleFields = (form, errors) => {
72
74
  };
73
75
 
74
76
  export function validateSchema(form) {
77
+ return validateSchemaWithContext(form, new ContextResolver(form));
78
+ }
79
+
80
+ // Internal entrypoint lets engine construction reuse its ownership index.
81
+ export function validateSchemaWithContext(form, contextResolver) {
75
82
  const fields = flattenFields(form.elements);
76
83
  const seenDataNames = new Set();
77
84
  const seenKeys = new Set();
78
85
  const duplicateDataNames = new Set();
79
86
  const duplicateKeys = new Set();
80
87
  const errors = [];
88
+ if (
89
+ form.repeatable_recalculation !== undefined &&
90
+ !['automatic', 'confirm'].includes(form.repeatable_recalculation)
91
+ )
92
+ errors.push('form.repeatable_recalculation must be automatic or confirm');
81
93
 
82
94
  const formAIValidation = validateFormAIMetadata(form);
83
95
  if (!formAIValidation.isValid) {
@@ -85,7 +97,7 @@ export function validateSchema(form) {
85
97
  }
86
98
 
87
99
  // Create a map of all fields by key and data_name for condition validation
88
- const allFields = {};
100
+ const allFields = Object.create(null);
89
101
  fields.forEach((field) => {
90
102
  if (field.key) {
91
103
  allFields[field.key] = field;
@@ -145,25 +157,18 @@ export function validateSchema(form) {
145
157
  errors.push(...fieldValidation.errors);
146
158
  }
147
159
 
148
- // Validate conditions for all field types
149
- if (field.visible_conditions) {
150
- const validation = validateFieldConditions(field, field.visible_conditions, allFields);
151
- if (!validation.isValid) {
152
- errors.push(...validation.errors);
153
- }
154
- }
155
-
156
- if (field.required_conditions) {
157
- const validation = validateFieldConditions(field, field.required_conditions, allFields);
158
- if (!validation.isValid) {
159
- errors.push(...validation.errors);
160
- }
161
- }
162
-
163
- if (field.read_only_conditions) {
164
- const validation = validateFieldConditions(field, field.read_only_conditions, allFields);
165
- if (!validation.isValid) {
166
- errors.push(...validation.errors);
160
+ for (const property of CONDITION_PROPERTIES) {
161
+ if (!field[property]) continue;
162
+ const conditionErrors = validateFieldConditions(field, field[property], allFields).errors;
163
+ errors.push(...conditionErrors);
164
+ for (const issue of getConditionReferenceIssues(
165
+ field,
166
+ property,
167
+ field[property],
168
+ allFields,
169
+ contextResolver
170
+ )) {
171
+ if (!conditionErrors.includes(issue.message)) errors.push(issue.message);
167
172
  }
168
173
  }
169
174
 
@@ -1,3 +1,4 @@
1
+ import { analyzeRepeatableCollections } from './repeatable-collections.js';
1
2
  import { ContextResolver } from '../engine/context-resolver.js';
2
3
  import { flattenFields } from './field-helpers.js';
3
4
 
@@ -378,7 +379,7 @@ export function removeEvalContents(code) {
378
379
  }
379
380
 
380
381
  export function extractStaticFieldReferenceMatches(code) {
381
- const cleanedCode = removeEvalContents(code);
382
+ const cleanedCode = removeEvalContents(analyzeRepeatableCollections(code).scalarSource);
382
383
  const fieldRegex = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
383
384
  const matches = [];
384
385
  let match;
@@ -1,3 +1,5 @@
1
+ import { analyzeRepeatableCollections } from './repeatable-collections.js';
2
+ import { createRecordEvaluator } from '../engine/record-evaluator.js';
1
3
  import { parse } from 'acorn';
2
4
  import { BUILTIN_CONTEXTS } from '../builtins/builtin-metadata.js';
3
5
  import {
@@ -363,6 +365,7 @@ function createSimulationErrorResult(runtimeError, warnings = []) {
363
365
  * @property {string | null} description
364
366
  * @property {string | null} displayStyle
365
367
  * @property {string[]} parentPath
368
+ * @property {boolean} [collectionOnly] Section references are valid only in collection calls.
366
369
  * @property {{
367
370
  * level: 'accessible' | 'restricted',
368
371
  * code: 'main_form' | 'same_repeatable_section' | 'ancestor_repeatable_context' | 'different_repeatable_section',
@@ -396,18 +399,20 @@ export function getCalculationBuiltinCatalog() {
396
399
  * @param {Object} options.schema
397
400
  * @param {string} options.fieldDataName
398
401
  * @param {boolean} [options.includeRestricted=false]
402
+ * @param {boolean} [options.includeCollections=false] Include collection-only section references.
399
403
  * @returns {CalculationReferenceDefinition[]}
400
404
  */
401
405
  export function getCalculationReferenceCatalog({
402
406
  schema,
403
407
  fieldDataName,
404
408
  includeRestricted = false,
409
+ includeCollections = false,
405
410
  }) {
406
411
  const form = normalizeCalculationFormSchema(schema);
407
412
  const resolver = new ContextResolver(form);
408
413
  const executionContext = buildCalculationExecutionContext(form, fieldDataName, resolver);
409
414
 
410
- return flattenFields(form.elements)
415
+ const scalarReferences = flattenFields(form.elements)
411
416
  .filter((field) => field && typeof field === 'object')
412
417
  .filter((field) => field.data_name && field.data_name !== fieldDataName)
413
418
  .filter(
@@ -419,6 +424,33 @@ export function getCalculationReferenceCatalog({
419
424
  .map((field) => toCalculationReferenceDefinition(field, resolver, executionContext))
420
425
  .filter((reference) => reference !== null)
421
426
  .filter((reference) => includeRestricted || reference.access.level === 'accessible');
427
+ if (!includeCollections) return scalarReferences;
428
+ const collections = [...resolver.repeatableSectionTree.values()]
429
+ .map((info) => {
430
+ const accessible =
431
+ ContextResolver.isPrefix(executionContext.parentPath, info.parentPath) ||
432
+ ContextResolver.isPrefix(info.parentPath, executionContext.parentPath);
433
+ return {
434
+ key: info.preferredKey,
435
+ reference: `$${info.field.data_name}`,
436
+ dataName: info.field.data_name,
437
+ label: info.field.label,
438
+ type: 'RepeatableSection',
439
+ description: info.field.description,
440
+ displayStyle: null,
441
+ parentPath: [...info.parentPath],
442
+ collectionOnly: true,
443
+ access: {
444
+ level: accessible ? 'accessible' : 'restricted',
445
+ code: accessible ? 'same_repeatable_section' : 'different_repeatable_section',
446
+ message: accessible ? null : 'Collection is outside the record ancestry.',
447
+ suggestion:
448
+ 'Use this reference only as the first argument of REPEATABLEVALUES or REPEATABLESUM.',
449
+ },
450
+ };
451
+ })
452
+ .filter((reference) => includeRestricted || reference.access.level === 'accessible');
453
+ return [...scalarReferences, ...collections];
422
454
  }
423
455
 
424
456
  /**
@@ -434,6 +466,7 @@ export function getCalculationReferenceCatalog({
434
466
  * issues: CalculationExpressionIssue[],
435
467
  * usedBuiltins: string[],
436
468
  * referencedFields: string[],
469
+ * collectionReferences: Array<{builtinName: string, sectionDataName: string, targetDataName: string, startIndex: number, endIndex: number}>,
437
470
  * }}
438
471
  */
439
472
  export function analyzeCalculationExpression({
@@ -651,6 +684,13 @@ export function analyzeCalculationExpression({
651
684
  );
652
685
  }
653
686
 
687
+ const collectionAnalysis = analyzeRepeatableCollections(
688
+ normalizedExpression,
689
+ resolver,
690
+ fieldDataName
691
+ );
692
+ for (const issue of collectionAnalysis.issues)
693
+ addIssue(issues, issueKeys, createIssue({ ...issue, severity: 'error' }));
654
694
  for (const reference of extractStaticFieldReferenceMatches(normalizedExpression)) {
655
695
  if (!referencedFieldNames.has(reference.fieldName)) {
656
696
  referencedFieldNames.add(reference.fieldName);
@@ -732,6 +772,13 @@ export function analyzeCalculationExpression({
732
772
  issues,
733
773
  usedBuiltins,
734
774
  referencedFields,
775
+ collectionReferences: collectionAnalysis.calls.map((call) => ({
776
+ builtinName: call.name,
777
+ sectionDataName: call.section,
778
+ targetDataName: call.target,
779
+ startIndex: call.index,
780
+ endIndex: call.index + call.length,
781
+ })),
735
782
  };
736
783
  }
737
784
 
@@ -757,6 +804,7 @@ export function analyzeCalculationExpression({
757
804
  * fieldContext: Object | null,
758
805
  * }>,
759
806
  * },
807
+ * runRecord: (options: {snapshot: Object, instancePath?: Object[], expression?: string}) => Promise<{result: unknown, runtimeError: string|null, warnings: Object[], evaluation?: Object}>,
760
808
  * dispose: () => void,
761
809
  * }}
762
810
  */
@@ -774,6 +822,10 @@ export function createCalculationPreviewSession({
774
822
  return {
775
823
  run: () =>
776
824
  createSimulationErrorResult(`Field '${fieldDataName}' does not exist in the form schema.`),
825
+ runRecord: async () =>
826
+ createSimulationErrorResult(
827
+ `Field '${fieldDataName}' does not exist or is not a CalculatedField.`
828
+ ),
777
829
  dispose: () => {},
778
830
  };
779
831
  }
@@ -781,6 +833,10 @@ export function createCalculationPreviewSession({
781
833
  if (targetField.type !== 'CalculatedField') {
782
834
  return {
783
835
  run: () => createSimulationErrorResult(`Field '${fieldDataName}' is not a CalculatedField.`),
836
+ runRecord: async () =>
837
+ createSimulationErrorResult(
838
+ `Field '${fieldDataName}' does not exist or is not a CalculatedField.`
839
+ ),
784
840
  dispose: () => {},
785
841
  };
786
842
  }
@@ -852,6 +908,43 @@ export function createCalculationPreviewSession({
852
908
  );
853
909
  }
854
910
  },
911
+ runRecord: async ({ snapshot, instancePath = [], expression: nextExpression, ...options }) => {
912
+ if (disposed)
913
+ return createSimulationErrorResult('Calculation preview session has been disposed.');
914
+ if (typeof nextExpression === 'string') targetField.calculate = nextExpression;
915
+ const evaluator = createRecordEvaluator({
916
+ schema: simulationSchema,
917
+ security,
918
+ warningSystem,
919
+ diagnostics: { console: false },
920
+ });
921
+ const evaluation = await evaluator.evaluate(snapshot, { ...options, mode: 'refresh' });
922
+ if (evaluation.status !== 'evaluated')
923
+ return {
924
+ ...createSimulationErrorResult(evaluation.issues[0]?.message || 'Record preview failed.'),
925
+ evaluation,
926
+ };
927
+ let record = evaluation.snapshot;
928
+ for (const segment of instancePath)
929
+ record = record?.repeatable?.[segment.repeatableKey]?.[segment.index];
930
+ if (!record?.values || !Object.hasOwn(record.values, fieldDataName))
931
+ return {
932
+ ...createSimulationErrorResult(
933
+ 'Preview instancePath must identify the record owning the calculated field.'
934
+ ),
935
+ evaluation,
936
+ };
937
+ const runtimeError =
938
+ evaluation.diagnostics.find(
939
+ (item) => item.fieldName === fieldDataName && item.severity === 'error'
940
+ )?.message || null;
941
+ return {
942
+ result: record?.values?.[fieldDataName] ?? null,
943
+ runtimeError,
944
+ warnings: sanitizeCollectedWarnings(warningSystem.getCollectedWarnings()),
945
+ evaluation,
946
+ };
947
+ },
855
948
  dispose: () => {
856
949
  disposed = true;
857
950
  warningSystem.clearCollectedWarnings();
@@ -0,0 +1,23 @@
1
+ /** Clone portable record data without copying immutable binary media into JS memory. */
2
+ export function copyRecordData(value, seen = new Map()) {
3
+ if (typeof value === 'function' || typeof value === 'symbol')
4
+ throw new Error('Record data must not contain functions or symbols.');
5
+ if (value === null || typeof value !== 'object') return value;
6
+ if (seen.has(value)) throw new Error('Record data must not contain cyclic references.');
7
+ if (value instanceof Date) return new Date(value.getTime());
8
+ if (typeof Blob === 'function' && value instanceof Blob) return value;
9
+ if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
10
+ throw new Error('Record data must use plain objects, arrays, dates or native media handles.');
11
+ const result = Array.isArray(value) ? [] : {};
12
+ seen.set(value, result);
13
+ for (const key of Object.keys(value)) {
14
+ Object.defineProperty(result, key, {
15
+ value: copyRecordData(value[key], seen),
16
+ enumerable: true,
17
+ writable: true,
18
+ configurable: true,
19
+ });
20
+ }
21
+ seen.delete(value);
22
+ return result;
23
+ }
@@ -0,0 +1,170 @@
1
+ import { parse } from 'acorn';
2
+ import { ContextResolver } from '../engine/context-resolver.js';
3
+
4
+ export const COLLECTION_NAMES = new Set(['REPEATABLEVALUES', 'REPEATABLESUM']);
5
+ const VALUE_TYPES = new Set([
6
+ 'TextField',
7
+ 'NumericField',
8
+ 'SingleChoiceField',
9
+ 'MultiChoiceField',
10
+ 'BooleanField',
11
+ 'DateField',
12
+ 'TimeField',
13
+ 'CalculatedField',
14
+ 'FormLinkField',
15
+ ]);
16
+
17
+ /** Parse schema references without interpreting collection sources as scalar values. */
18
+ export function analyzeRepeatableCollections(source, resolver = null, owner = null) {
19
+ const calls = [];
20
+ const issues = [];
21
+ if (!/\bREPEATABLE(?:VALUES|SUM)\b/.test(source || ''))
22
+ return { calls, issues, expression: source, scalarSource: source };
23
+ let tree;
24
+ try {
25
+ tree = parse(source, { ecmaVersion: 'latest', sourceType: 'commonjs' });
26
+ } catch {
27
+ return { calls, issues, expression: source, scalarSource: source };
28
+ }
29
+ const edits = [];
30
+ function visit(node, parent) {
31
+ if (!node || typeof node !== 'object') return;
32
+ if (
33
+ node.type === 'CallExpression' &&
34
+ node.callee?.name === 'EVAL' &&
35
+ node.arguments.some(
36
+ (argument) =>
37
+ argument.type === 'Literal' &&
38
+ typeof argument.value === 'string' &&
39
+ /\bREPEATABLE(?:VALUES|SUM)\s*\(/.test(argument.value)
40
+ )
41
+ )
42
+ issues.push({
43
+ code: 'dynamic_collection_unsupported',
44
+ message: 'Repeatable collection calls inside EVAL are unsupported; use direct calls.',
45
+ index: node.start,
46
+ length: node.end - node.start,
47
+ });
48
+ if (node.type === 'Identifier' && COLLECTION_NAMES.has(node.name)) {
49
+ if (parent?.type !== 'CallExpression' || parent.callee !== node) {
50
+ issues.push({
51
+ code: 'collection_alias_unsupported',
52
+ message: 'Repeatable collection builtins require direct calls.',
53
+ index: node.start,
54
+ length: node.end - node.start,
55
+ });
56
+ } else {
57
+ const [section, target] = parent.arguments;
58
+ const valid =
59
+ parent.arguments.length === 2 &&
60
+ section?.type === 'Identifier' &&
61
+ /^\$[a-zA-Z_][a-zA-Z0-9_]*$/.test(section.name) &&
62
+ target?.type === 'Literal' &&
63
+ typeof target.value === 'string';
64
+ if (!valid) {
65
+ issues.push({
66
+ code: 'invalid_collection_arguments',
67
+ message: `${node.name} requires a direct $repeatable_data_name and a literal target data_name.`,
68
+ index: parent.start,
69
+ length: parent.end - parent.start,
70
+ });
71
+ } else {
72
+ const call = {
73
+ name: node.name,
74
+ section: section.name.slice(1),
75
+ target: target.value,
76
+ index: section.start,
77
+ length: section.end - section.start,
78
+ };
79
+ calls.push(call);
80
+ edits.push({
81
+ start: section.start,
82
+ end: section.end,
83
+ value: JSON.stringify(call.section),
84
+ });
85
+ if (resolver) {
86
+ const info = resolver.repeatableSectionTree.get(call.section);
87
+ const fieldInfo = resolver.fieldOwnership.get(call.target);
88
+ const ownerPath = resolver.getFieldInfo(owner)?.parentPath || [];
89
+ let message = null;
90
+ if (!info) message = `Unknown RepeatableSection '${call.section}'.`;
91
+ else if (
92
+ !fieldInfo ||
93
+ !ContextResolver.pathsEqual(fieldInfo.parentPath, info.currentPath)
94
+ )
95
+ message = `Collection target '${call.target}' must belong to '${call.section}' rows.`;
96
+ else if (!VALUE_TYPES.has(fieldInfo.field.type))
97
+ message = `Collection target type ${fieldInfo.field.type} is unsupported.`;
98
+ else if (
99
+ call.name === 'REPEATABLESUM' &&
100
+ !['NumericField', 'CalculatedField'].includes(fieldInfo.field.type)
101
+ )
102
+ message = 'REPEATABLESUM requires a NumericField or CalculatedField target.';
103
+ else if (
104
+ !ContextResolver.isPrefix(ownerPath, info.parentPath) &&
105
+ !ContextResolver.isPrefix(info.parentPath, ownerPath)
106
+ )
107
+ message = `Collection source '${call.section}' is outside the calculation's record ancestry.`;
108
+ if (message)
109
+ issues.push({
110
+ code: 'invalid_collection_reference',
111
+ message,
112
+ index: parent.start,
113
+ length: parent.end - parent.start,
114
+ });
115
+ }
116
+ }
117
+ }
118
+ }
119
+ for (const [key, value] of Object.entries(node)) {
120
+ if (key === 'start' || key === 'end') continue;
121
+ if (Array.isArray(value)) value.forEach((child) => visit(child, node));
122
+ else if (value && typeof value === 'object') visit(value, node);
123
+ }
124
+ }
125
+ visit(tree, null);
126
+ let expression = source;
127
+ let scalarSource = source;
128
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
129
+ expression = expression.slice(0, edit.start) + edit.value + expression.slice(edit.end);
130
+ scalarSource =
131
+ scalarSource.slice(0, edit.start) +
132
+ ' '.repeat(edit.end - edit.start) +
133
+ scalarSource.slice(edit.end);
134
+ }
135
+ return { calls, issues, expression, scalarSource };
136
+ }
137
+
138
+ export function projectCollectionValue(field, value) {
139
+ if (value == null) return ['MultiChoiceField', 'FormLinkField'].includes(field.type) ? [] : null;
140
+ const fail = () => {
141
+ throw new Error(`Malformed collection value for '${field.data_name}'.`);
142
+ };
143
+ if (['SingleChoiceField', 'MultiChoiceField', 'BooleanField'].includes(field.type)) {
144
+ if (typeof value !== 'object' || Array.isArray(value)) return fail();
145
+ const multiple = field.type === 'MultiChoiceField';
146
+ const selections = value[multiple ? 'choices' : 'choice'];
147
+ const others = value.other ?? (field.type === 'BooleanField' ? [] : undefined);
148
+ if (!Array.isArray(selections) || !Array.isArray(others)) return fail();
149
+ if ((field.type === 'BooleanField' || !field.allow_other) && others.length) return fail();
150
+ const allowed = new Set(field.choices.map((choice) => choice.value));
151
+ const projected = selections.map((entry) => {
152
+ if (!entry || !allowed.has(entry.value)) return fail();
153
+ return entry.value;
154
+ });
155
+ for (const entry of others) {
156
+ if (!entry || typeof entry.label !== 'string' || !entry.label.trim()) return fail();
157
+ projected.push(entry.label);
158
+ }
159
+ if (!multiple && projected.length > 1) return fail();
160
+ return multiple ? projected : (projected[0] ?? null);
161
+ }
162
+ if (field.type === 'FormLinkField') {
163
+ if (!Array.isArray(value) || (!field.allow_multiple_records && value.length > 1)) return fail();
164
+ return value.map((entry) => {
165
+ if (!entry || typeof entry.record_id !== 'string' || !entry.record_id.trim()) return fail();
166
+ return { record_id: entry.record_id };
167
+ });
168
+ }
169
+ return value;
170
+ }