form0-core 0.6.0 → 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.
- package/README.md +33 -0
- package/package.json +5 -3
- package/src/authoring/form-authoring.js +16 -26
- package/src/builtins/builtin-definitions.js +22 -0
- package/src/builtins/control/eval.js +11 -2
- package/src/builtins/registry.js +4 -0
- package/src/builtins/repeatable/repeatablesum.js +12 -0
- package/src/builtins/repeatable/repeatablevalues.js +12 -0
- package/src/engine/calculation.js +100 -37
- package/src/engine/evaluator.js +13 -5
- package/src/engine/event-source.js +37 -29
- package/src/engine/events.js +6 -2
- package/src/engine/form-engine.js +1 -1
- package/src/engine/managed-events.js +27 -9
- package/src/engine/record-context.js +126 -0
- package/src/engine/record-evaluator.js +657 -0
- package/src/engine/record-freshness.js +19 -0
- package/src/engine/submission-validation.js +125 -0
- package/src/index.js +3 -0
- package/src/schema/schema-validator.js +5 -0
- package/src/utilities/calculation-dependencies.js +2 -1
- package/src/utilities/calculation-editor.js +94 -1
- package/src/utilities/record-data.js +23 -0
- package/src/utilities/repeatable-collections.js +170 -0
- package/src/utilities/schema-revision.js +30 -0
|
@@ -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
|
+
}
|
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';
|
|
@@ -85,6 +85,11 @@ export function validateSchemaWithContext(form, contextResolver) {
|
|
|
85
85
|
const duplicateDataNames = new Set();
|
|
86
86
|
const duplicateKeys = new Set();
|
|
87
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');
|
|
88
93
|
|
|
89
94
|
const formAIValidation = validateFormAIMetadata(form);
|
|
90
95
|
if (!formAIValidation.isValid) {
|
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function stableValue(value) {
|
|
2
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
3
|
+
if (!value || typeof value !== 'object') return value;
|
|
4
|
+
return Object.fromEntries(
|
|
5
|
+
Object.keys(value)
|
|
6
|
+
.sort()
|
|
7
|
+
.map((key) => [key, stableValue(value[key])])
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fnv1a64(input) {
|
|
12
|
+
let hash = 0xcbf29ce484222325n;
|
|
13
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
14
|
+
hash ^= BigInt(input.charCodeAt(index));
|
|
15
|
+
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
|
|
16
|
+
}
|
|
17
|
+
return hash.toString(16).padStart(16, '0');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Return a deterministic schema revision without importing authoring/editor modules. */
|
|
21
|
+
export function getFormSchemaRevision(schema) {
|
|
22
|
+
const root =
|
|
23
|
+
schema?.form && Array.isArray(schema.form.elements)
|
|
24
|
+
? schema
|
|
25
|
+
: schema && Array.isArray(schema.elements)
|
|
26
|
+
? { form: schema }
|
|
27
|
+
: null;
|
|
28
|
+
if (!root) throw new Error('Expected a root schema containing form.elements or a form schema');
|
|
29
|
+
return `form0-${fnv1a64(JSON.stringify(stableValue(root)))}`;
|
|
30
|
+
}
|