form0-core 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "form0-core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
@@ -12,7 +12,7 @@
12
12
  "SECURITY.md"
13
13
  ],
14
14
  "scripts": {
15
- "test": "node tests/form0-core.test.js && node tests/conditions.test.js && node tests/numeric-field-schema.test.js && node tests/dataset-descriptors.test.js && node tests/structured-record.test.js && node tests/title-fields.test.js && node tests/builtin-metadata.test.js && node tests/builtin-import-isolation.test.js && node tests/builtin-catalog-examples.test.js && node tests/date-builtins.test.js && node tests/calculation-expression-utils.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/form-authoring.test.js && node tests/calculation-dependency-engine.test.js && node tests/calculation-diagnostics.characterization.test.js && node tests/calculation-diagnostics.test.js && node tests/calculation-missing-result.test.js && node tests/evaluator-reentry.test.js && node tests/event-compatibility.characterization.test.js && node tests/event-hermes.test.js && node tests/event-async-isolation.test.js && node tests/managed-events.test.js && node tests/security-test.js",
15
+ "test": "node tests/form0-core.test.js && node tests/conditions.test.js && node tests/condition-scope.test.js && node tests/numeric-field-schema.test.js && node tests/dataset-descriptors.test.js && node tests/structured-record.test.js && node tests/title-fields.test.js && node tests/builtin-metadata.test.js && node tests/builtin-import-isolation.test.js && node tests/builtin-catalog-examples.test.js && node tests/date-builtins.test.js && node tests/calculation-expression-utils.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/form-authoring.test.js && node tests/calculation-dependency-engine.test.js && node tests/calculation-diagnostics.characterization.test.js && node tests/calculation-diagnostics.test.js && node tests/calculation-missing-result.test.js && node tests/evaluator-reentry.test.js && node tests/event-compatibility.characterization.test.js && node tests/event-hermes.test.js && node tests/event-async-isolation.test.js && node tests/managed-events.test.js && node tests/security-test.js",
16
16
  "format": "prettier --write \"src/**/*.{js,jsx}\" \"tests/**/*.{js,jsx}\" \"scripts/**/*.{js,mjs}\" package.json README.md EVENT_CONTRACT.md SECURITY.md SUPPORT.md CONTRIBUTING.md eslint.config.js .github/workflows/ci.yml .github/workflows/publish.yml",
17
17
  "format:check": "prettier --check \"src/**/*.{js,jsx}\" \"tests/**/*.{js,jsx}\" \"scripts/**/*.{js,mjs}\" package.json README.md EVENT_CONTRACT.md SECURITY.md SUPPORT.md CONTRIBUTING.md eslint.config.js .github/workflows/ci.yml .github/workflows/publish.yml",
18
18
  "lint:check": "eslint src tests",
@@ -0,0 +1,90 @@
1
+ import { flattenFields } from '../utilities/field-helpers.js';
2
+ import { ContextResolver } from './context-resolver.js';
3
+
4
+ export const CONDITION_PROPERTIES = [
5
+ 'visible_conditions',
6
+ 'required_conditions',
7
+ 'read_only_conditions',
8
+ ];
9
+
10
+ /** Visit every reference, independently of boolean short-circuiting. */
11
+ export function forEachConditionReference(conditions, visit) {
12
+ if (Array.isArray(conditions)) {
13
+ conditions.forEach((condition) => forEachConditionReference(condition, visit));
14
+ return;
15
+ }
16
+ if (!conditions || typeof conditions !== 'object') return;
17
+ if (conditions.field_id) visit(conditions);
18
+ if (conditions.and) forEachConditionReference(conditions.and, visit);
19
+ if (conditions.or) forEachConditionReference(conditions.or, visit);
20
+ }
21
+
22
+ /** Engine-private index, built once and shared by all three condition maps. */
23
+ export function createConditionContext(schema, contextResolver = null, warningSystem = null) {
24
+ const fields = flattenFields(schema.elements);
25
+ const allFields = Object.create(null);
26
+ for (const field of fields) {
27
+ if (field.key) allFields[field.key] = field;
28
+ if (field.data_name) allFields[field.data_name] = field;
29
+ }
30
+ return {
31
+ fields,
32
+ allFields,
33
+ contextResolver: contextResolver || new ContextResolver(schema),
34
+ warningSystem,
35
+ };
36
+ }
37
+
38
+ export function getConditionReferenceIssues(owner, property, conditions, allFields, resolver) {
39
+ const issues = [];
40
+ const executionContext = {
41
+ type: 'condition',
42
+ fieldName: owner.data_name,
43
+ conditionProperty: property,
44
+ parentPath: [...(resolver.elementOwnership.get(owner.data_name)?.parentPath || [])],
45
+ };
46
+ forEachConditionReference(conditions, (condition) => {
47
+ const reference = condition.field_id;
48
+ const field = allFields[reference];
49
+ if (!field) {
50
+ issues.push({
51
+ reason: 'not_found',
52
+ reference,
53
+ executionContext,
54
+ message: `Condition references unknown field "${reference}" in field "${owner.data_name}"`,
55
+ });
56
+ return;
57
+ }
58
+ const access = resolver.resolveFieldAccessInfo(executionContext, field.data_name);
59
+ if (access.level === 'accessible') return;
60
+ const ownerPath = resolver.formatRepeatableSectionPath(executionContext.parentPath);
61
+ const referencePath = resolver.formatRepeatableSectionPath(access.fieldInfo?.parentPath || []);
62
+ issues.push({
63
+ reason: access.level === 'not_found' ? 'not_found' : 'restricted',
64
+ reference: field.data_name,
65
+ executionContext,
66
+ message: `Field "${owner.data_name}" ${property} references "${reference}" outside its record scope (owner: ${ownerPath}; reference: ${referencePath})`,
67
+ });
68
+ });
69
+ return issues;
70
+ }
71
+
72
+ export function assertConditionReferences(conditions, owner, property, context) {
73
+ const issues = getConditionReferenceIssues(
74
+ owner,
75
+ property,
76
+ conditions,
77
+ context.allFields,
78
+ context.contextResolver
79
+ );
80
+ if (!issues.length) return;
81
+ for (const issue of issues) {
82
+ const warning = context.contextResolver.generateAccessWarning(
83
+ issue.executionContext,
84
+ issue.reference,
85
+ issue.reason
86
+ );
87
+ context.warningSystem?.emitWarning({ ...warning, message: issue.message });
88
+ }
89
+ throw new Error(issues.map((issue) => issue.message).join('\n'));
90
+ }
@@ -1,4 +1,4 @@
1
- import { flattenFields } from '../utilities/field-helpers.js';
1
+ import { assertConditionReferences, createConditionContext } from './condition-context.js';
2
2
 
3
3
  const valuesEqual = (a, b) => {
4
4
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -81,37 +81,50 @@ function normalizeConditionValue(field, value) {
81
81
  return value;
82
82
  }
83
83
 
84
- export function evaluateConditions(conditions, values, allFields) {
84
+ export function evaluateConditions(conditions, values, allFields, scope = null) {
85
+ if (scope) {
86
+ assertConditionReferences(conditions, scope.owner, scope.property, scope.context);
87
+ }
88
+ return evaluateConditionExpression(conditions, values, allFields);
89
+ }
90
+
91
+ function evaluateConditionExpression(conditions, values, allFields) {
85
92
  if (Array.isArray(conditions)) {
86
- return conditions.every((c) => evaluateConditions(c, values, allFields));
93
+ return conditions.every((c) => evaluateConditionExpression(c, values, allFields));
87
94
  }
88
95
 
89
96
  if (conditions.and) {
90
- return conditions.and.every((c) => evaluateConditions(c, values, allFields));
97
+ return conditions.and.every((c) => evaluateConditionExpression(c, values, allFields));
91
98
  }
92
99
 
93
100
  if (conditions.or) {
94
- return conditions.or.some((c) => evaluateConditions(c, values, allFields));
101
+ return conditions.or.some((c) => evaluateConditionExpression(c, values, allFields));
95
102
  }
96
103
 
97
104
  // Map field_id (key) to data_name for value lookup
98
105
  const field = allFields ? allFields[conditions.field_id] : undefined;
99
106
  const dataName = field ? field.data_name : undefined;
100
107
  const val = normalizeConditionValue(field, dataName ? values[dataName] : undefined);
101
- // Debug log: show what is being checked
102
- //console.log('[evaluateConditions] field_id:', conditions.field_id, 'data_name:', dataName, 'value in values:', val, 'expected:', conditions.value, 'operator:', conditions.operator);
103
108
  const fn = OPERATORS[conditions.operator];
104
109
  return fn ? fn(val, conditions.value) : false;
105
110
  }
106
111
 
112
+ function evaluateElementCondition(owner, property, values, context) {
113
+ return evaluateConditions(owner[property], values, context.allFields, {
114
+ owner,
115
+ property,
116
+ context,
117
+ });
118
+ }
119
+
107
120
  /**
108
121
  * Recursively evaluates visibility for both fields and sections in bottom-up order
109
122
  * @param {Array} elements - Form elements to evaluate
110
123
  * @param {Object} values - Current form values
111
124
  * @param {Object} visible - Visibility state object to populate
112
- * @param {Object} allFields - Map of all fields by key for condition evaluation
125
+ * @param {Object} context - Shared field index and ownership resolver
113
126
  */
114
- function evaluateVisibilityRecursive(elements, values, visible, allFields) {
127
+ function evaluateVisibilityRecursive(elements, values, visible, context) {
115
128
  elements.forEach((element) => {
116
129
  if (
117
130
  element.type === 'Section' ||
@@ -120,7 +133,7 @@ function evaluateVisibilityRecursive(elements, values, visible, allFields) {
120
133
  ) {
121
134
  // First evaluate children (bottom-up approach)
122
135
  if (element.elements && element.elements.length > 0) {
123
- evaluateVisibilityRecursive(element.elements, values, visible, allFields);
136
+ evaluateVisibilityRecursive(element.elements, values, visible, context);
124
137
 
125
138
  // Check if any child is visible
126
139
  const hasVisibleChild = element.elements.some(
@@ -133,7 +146,12 @@ function evaluateVisibilityRecursive(elements, values, visible, allFields) {
133
146
  } else {
134
147
  // At least one child visible → respect explicit section visibility settings
135
148
  if (element.visible_conditions) {
136
- const isVisible = evaluateConditions(element.visible_conditions, values, allFields);
149
+ const isVisible = evaluateElementCondition(
150
+ element,
151
+ 'visible_conditions',
152
+ values,
153
+ context
154
+ );
137
155
  visible[element.data_name] = isVisible;
138
156
  } else {
139
157
  visible[element.data_name] = element.visible === true;
@@ -142,7 +160,12 @@ function evaluateVisibilityRecursive(elements, values, visible, allFields) {
142
160
  } else {
143
161
  // Empty section fallback (though schema validation should prevent this)
144
162
  if (element.visible_conditions) {
145
- const isVisible = evaluateConditions(element.visible_conditions, values, allFields);
163
+ const isVisible = evaluateElementCondition(
164
+ element,
165
+ 'visible_conditions',
166
+ values,
167
+ context
168
+ );
146
169
  visible[element.data_name] = isVisible;
147
170
  } else {
148
171
  visible[element.data_name] = element.visible === true;
@@ -151,7 +174,7 @@ function evaluateVisibilityRecursive(elements, values, visible, allFields) {
151
174
  } else {
152
175
  // Regular field - existing logic
153
176
  if (element.visible_conditions) {
154
- const isVisible = evaluateConditions(element.visible_conditions, values, allFields);
177
+ const isVisible = evaluateElementCondition(element, 'visible_conditions', values, context);
155
178
  visible[element.data_name] = isVisible;
156
179
  } else {
157
180
  visible[element.data_name] = element.visible === true;
@@ -160,34 +183,24 @@ function evaluateVisibilityRecursive(elements, values, visible, allFields) {
160
183
  });
161
184
  }
162
185
 
163
- export function evaluateVisibility(schema, values, visible) {
164
- // Build allFields map by key using flattened fields for condition evaluation
165
- // PERFORMANCE NOTE: For large/static schemas, consider caching/memoizing allFields per schema
166
- // to avoid rebuilding on every evaluation. See #perf-caching-idea.
167
- const fields = flattenFields(schema.elements);
168
- const allFields = {};
169
- fields.forEach((field) => {
170
- if (field.key) allFields[field.key] = field;
171
- if (field.data_name) allFields[field.data_name] = field;
172
- });
173
-
174
- // Use recursive approach for single-pass visibility evaluation
175
- evaluateVisibilityRecursive(schema.elements, values, visible, allFields);
186
+ export function evaluateVisibility(
187
+ schema,
188
+ values,
189
+ visible,
190
+ context = createConditionContext(schema)
191
+ ) {
192
+ evaluateVisibilityRecursive(schema.elements, values, visible, context);
176
193
  }
177
194
 
178
- export function evaluateRequirement(schema, values, required) {
179
- const fields = flattenFields(schema.elements);
180
- // Build allFields map by key
181
- // PERFORMANCE NOTE: For large/static schemas, consider caching/memoizing allFields per schema
182
- // to avoid rebuilding on every evaluation. See #perf-caching-idea.
183
- const allFields = {};
184
- fields.forEach((field) => {
185
- if (field.key) allFields[field.key] = field;
186
- if (field.data_name) allFields[field.data_name] = field;
187
- });
188
- fields.forEach((field) => {
195
+ export function evaluateRequirement(
196
+ schema,
197
+ values,
198
+ required,
199
+ context = createConditionContext(schema)
200
+ ) {
201
+ context.fields.forEach((field) => {
189
202
  if (field.required_conditions) {
190
- const isRequired = evaluateConditions(field.required_conditions, values, allFields);
203
+ const isRequired = evaluateElementCondition(field, 'required_conditions', values, context);
191
204
  required[field.data_name] = isRequired;
192
205
  } else {
193
206
  required[field.data_name] = field.required === true;
@@ -195,19 +208,15 @@ export function evaluateRequirement(schema, values, required) {
195
208
  });
196
209
  }
197
210
 
198
- export function evaluateReadOnly(schema, values, read_only) {
199
- const fields = flattenFields(schema.elements);
200
- // Build allFields map by key
201
- // PERFORMANCE NOTE: For large/static schemas, consider caching/memoizing allFields per schema
202
- // to avoid rebuilding on every evaluation. See #perf-caching-idea.
203
- const allFields = {};
204
- fields.forEach((field) => {
205
- if (field.key) allFields[field.key] = field;
206
- if (field.data_name) allFields[field.data_name] = field;
207
- });
208
- fields.forEach((field) => {
211
+ export function evaluateReadOnly(
212
+ schema,
213
+ values,
214
+ read_only,
215
+ context = createConditionContext(schema)
216
+ ) {
217
+ context.fields.forEach((field) => {
209
218
  if (field.read_only_conditions) {
210
- const isReadOnly = evaluateConditions(field.read_only_conditions, values, allFields);
219
+ const isReadOnly = evaluateElementCondition(field, 'read_only_conditions', values, context);
211
220
  read_only[field.data_name] = isReadOnly;
212
221
  } else {
213
222
  read_only[field.data_name] = field.read_only === true;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Context-aware field resolution for calculations and events
2
+ * Context-aware field resolution for calculations, conditions and events
3
3
  * Determines which fields are accessible based on execution context
4
4
  * Reuses tree-building logic from record-transformer.js
5
5
  */
@@ -10,6 +10,7 @@ export class ContextResolver {
10
10
  constructor(schema) {
11
11
  // Maps to store field context information (similar to record-transformer.js)
12
12
  this.fieldOwnership = new Map(); // Field data_name -> { preferredKey, field, parentPath }
13
+ this.elementOwnership = new Map(); // Includes containers in their containing record context.
13
14
  this.repeatableSectionTree = new Map(); // RepeatableSection data_name -> tree info
14
15
  this.sectionFields = new Set(); // Section field names (organizational only)
15
16
 
@@ -26,6 +27,12 @@ export class ContextResolver {
26
27
  if (!Array.isArray(elements)) return;
27
28
 
28
29
  elements.forEach((element) => {
30
+ const preferredKey =
31
+ typeof element.key === 'string' && element.key.trim() !== ''
32
+ ? element.key
33
+ : element.data_name;
34
+ const ownership = { preferredKey, field: element, parentPath: [...parentPath] };
35
+ this.elementOwnership.set(element.data_name, ownership);
29
36
  if (element.type === 'Section' || element.type === 'BuildingPlanSection') {
30
37
  this.sectionFields.add(element.data_name);
31
38
  // Recursively process Section children with same parentPath
@@ -34,8 +41,6 @@ export class ContextResolver {
34
41
  this.buildFieldContext(element.elements, parentPath);
35
42
  }
36
43
  } else if (element.type === 'RepeatableSection') {
37
- const preferredKey =
38
- element.key && element.key.trim() !== '' ? element.key : element.data_name;
39
44
  const currentPath = [...parentPath, preferredKey];
40
45
 
41
46
  // Store this RepeatableSection in the tree
@@ -54,13 +59,7 @@ export class ContextResolver {
54
59
  }
55
60
  } else {
56
61
  // This is a regular field - determine its ownership
57
- const preferredFieldKey =
58
- element.key && element.key.trim() !== '' ? element.key : element.data_name;
59
- this.fieldOwnership.set(element.data_name, {
60
- preferredKey: preferredFieldKey,
61
- field: element,
62
- parentPath: [...parentPath], // Copy the current path
63
- });
62
+ this.fieldOwnership.set(element.data_name, ownership);
64
63
  }
65
64
  });
66
65
  }
@@ -146,6 +145,19 @@ export class ContextResolver {
146
145
  checkRepeatableSectionAccessInfo(executionContext, fieldName, fieldInfo) {
147
146
  const { type: contextType, eventType, fieldName: contextFieldName } = executionContext;
148
147
 
148
+ if (contextType === 'condition') {
149
+ const owner = this.elementOwnership.get(contextFieldName);
150
+ if (owner) {
151
+ return this.resolveRepeatableSectionRelationship(
152
+ owner.parentPath,
153
+ fieldInfo.parentPath,
154
+ fieldName,
155
+ fieldInfo,
156
+ executionContext
157
+ );
158
+ }
159
+ }
160
+
149
161
  if (contextType === 'calculation') {
150
162
  // CalculatedField can only access fields in same RepeatableSection context
151
163
  const calculationFieldInfo = this.fieldOwnership.get(contextFieldName);
@@ -318,6 +330,10 @@ export class ContextResolver {
318
330
  buildRestrictedAccessSuggestion(executionContext, fieldName, fieldInfo, code) {
319
331
  const { type: contextType, eventType } = executionContext;
320
332
 
333
+ if (contextType === 'condition') {
334
+ return `Conditions can only reference fields in their containing record or its ancestors. Field '${fieldName}' belongs to: ${this.formatRepeatableSectionPath(fieldInfo.parentPath)}`;
335
+ }
336
+
321
337
  if (contextType === 'calculation') {
322
338
  if (code === 'different_repeatable_section') {
323
339
  return `CalculatedField can only access fields in the same RepeatableSection or ancestor contexts. Field '${fieldName}' is in RepeatableSection: ${this.formatRepeatableSectionPath(fieldInfo.parentPath)}`;
@@ -4,7 +4,8 @@ import { evaluateRequirement } from './conditions.js';
4
4
  import { evaluateReadOnly } from './conditions.js';
5
5
  import { validateFields } from './field-validation.js';
6
6
  import { calculationBuiltins, eventBuiltins } from '../builtins/registry.js';
7
- import { validateSchema } from '../schema/schema-validator.js';
7
+ import { validateSchemaWithContext } from '../schema/schema-validator.js';
8
+ import { createConditionContext } from './condition-context.js';
8
9
  import { flattenFields } from '../utilities/field-helpers.js';
9
10
  import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
10
11
  import { EventManager } from './events.js';
@@ -77,7 +78,8 @@ export function createFormEngine({
77
78
  }) {
78
79
  const { schema: preparedSchema, buildingPlanMeta } = expandBuildingPlanSchema(schema);
79
80
 
80
- validateSchema(preparedSchema.form);
81
+ const contextResolver = new ContextResolver(preparedSchema.form);
82
+ validateSchemaWithContext(preparedSchema.form, contextResolver);
81
83
 
82
84
  const { form } = preparedSchema;
83
85
  const values = { ...initialValues };
@@ -147,9 +149,9 @@ export function createFormEngine({
147
149
  const allHelpers = { ...calculationBuiltins, ...helpers };
148
150
 
149
151
  // Initialize context resolution system
150
- const contextResolver = new ContextResolver(form);
151
152
  const calculationPlan = buildCalculationDependencyPlan(form, contextResolver);
152
153
  const sharedWarningSystem = warningSystem || new WarningSystem();
154
+ const conditionContext = createConditionContext(form, contextResolver, sharedWarningSystem);
153
155
  const calculationDiagnostics = createCalculationDiagnostics(
154
156
  diagnostics,
155
157
  onDiagnostics,
@@ -196,9 +198,9 @@ export function createFormEngine({
196
198
  calculationPlan,
197
199
  calculationDiagnostics
198
200
  );
199
- evaluateRequirement(form, values, required);
200
- evaluateVisibility(form, values, visible);
201
- evaluateReadOnly(form, values, read_only);
201
+ evaluateRequirement(form, values, required, conditionContext);
202
+ evaluateVisibility(form, values, visible, conditionContext);
203
+ evaluateReadOnly(form, values, read_only, conditionContext);
202
204
  validateFields(form, values, errors);
203
205
  } catch (error) {
204
206
  calculationDiagnostics.abort(frame);
@@ -9,8 +9,14 @@ function copy(value, seen = new Map()) {
9
9
  if (value === null || typeof value !== 'object') return value;
10
10
  if (seen.has(value)) return seen.get(value);
11
11
  if (value instanceof Date) return new Date(value.getTime());
12
+ // Media controls retain native binary handles. Copy their immutable data and standard metadata
13
+ // without serializing custom properties or loading the bytes into JavaScript memory.
14
+ if (typeof File === 'function' && value instanceof File)
15
+ return new File([value], value.name, { type: value.type, lastModified: value.lastModified });
16
+ if (typeof Blob === 'function' && value instanceof Blob)
17
+ return Blob.prototype.slice.call(value, 0, value.size, value.type);
12
18
  if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
13
- throw new Error('Event data must use plain objects, arrays or dates.');
19
+ throw new Error('Event data must use plain objects, arrays, dates or native media handles.');
14
20
  const result = Array.isArray(value) ? [] : {};
15
21
  seen.set(value, result);
16
22
  for (const key of Object.keys(value)) {
@@ -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}'`;
@@ -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,6 +74,11 @@ 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();
@@ -85,7 +92,7 @@ export function validateSchema(form) {
85
92
  }
86
93
 
87
94
  // Create a map of all fields by key and data_name for condition validation
88
- const allFields = {};
95
+ const allFields = Object.create(null);
89
96
  fields.forEach((field) => {
90
97
  if (field.key) {
91
98
  allFields[field.key] = field;
@@ -145,25 +152,18 @@ export function validateSchema(form) {
145
152
  errors.push(...fieldValidation.errors);
146
153
  }
147
154
 
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);
155
+ for (const property of CONDITION_PROPERTIES) {
156
+ if (!field[property]) continue;
157
+ const conditionErrors = validateFieldConditions(field, field[property], allFields).errors;
158
+ errors.push(...conditionErrors);
159
+ for (const issue of getConditionReferenceIssues(
160
+ field,
161
+ property,
162
+ field[property],
163
+ allFields,
164
+ contextResolver
165
+ )) {
166
+ if (!conditionErrors.includes(issue.message)) errors.push(issue.message);
167
167
  }
168
168
  }
169
169