form0-core 0.1.3 → 0.1.4

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.3",
3
+ "version": "0.1.4",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
@@ -66,25 +66,48 @@ export class ContextResolver {
66
66
  }
67
67
 
68
68
  /**
69
- * Determine if a field is accessible from the current execution context
69
+ * Resolve field access with structured metadata for editor integrations.
70
70
  * @param {Object} executionContext - Current execution context
71
71
  * @param {string} fieldName - Field data_name being accessed
72
- * @returns {string} 'accessible', 'restricted', or 'not_found'
72
+ * @param {Object | null} [providedFieldInfo]
73
+ * @returns {{
74
+ * level: 'accessible' | 'restricted' | 'not_found',
75
+ * code: 'main_form' | 'same_repeatable_section' | 'ancestor_repeatable_context' | 'different_repeatable_section' | null,
76
+ * message: string | null,
77
+ * suggestion: string | null,
78
+ * fieldInfo: Object | null,
79
+ * }}
73
80
  */
74
- resolveFieldAccess(executionContext, fieldName) {
75
- const fieldInfo = this.fieldOwnership.get(fieldName);
81
+ resolveFieldAccessInfo(executionContext, fieldName, providedFieldInfo = null) {
82
+ const fieldInfo = providedFieldInfo || this.fieldOwnership.get(fieldName) || null;
76
83
 
77
84
  if (!fieldInfo) {
78
- return 'not_found';
85
+ return {
86
+ level: 'not_found',
87
+ code: null,
88
+ message: `Field '${fieldName}' does not exist in the form schema`,
89
+ suggestion: `Check that field '${fieldName}' exists in the form schema`,
90
+ fieldInfo: null,
91
+ };
79
92
  }
80
93
 
81
94
  // Main form fields (no parent RepeatableSection) are always accessible
82
95
  if (fieldInfo.parentPath.length === 0) {
83
- return 'accessible';
96
+ return this.buildAccessInfo('accessible', 'main_form', fieldName, fieldInfo, executionContext);
84
97
  }
85
98
 
86
99
  // RepeatableSection field access depends on execution context
87
- return this.checkRepeatableSectionAccess(executionContext, fieldInfo);
100
+ return this.checkRepeatableSectionAccessInfo(executionContext, fieldName, fieldInfo);
101
+ }
102
+
103
+ /**
104
+ * Determine if a field is accessible from the current execution context
105
+ * @param {Object} executionContext - Current execution context
106
+ * @param {string} fieldName - Field data_name being accessed
107
+ * @returns {string} 'accessible', 'restricted', or 'not_found'
108
+ */
109
+ resolveFieldAccess(executionContext, fieldName) {
110
+ return this.resolveFieldAccessInfo(executionContext, fieldName).level;
88
111
  }
89
112
 
90
113
  /**
@@ -94,22 +117,49 @@ export class ContextResolver {
94
117
  * @returns {string} 'accessible' or 'restricted'
95
118
  */
96
119
  checkRepeatableSectionAccess(executionContext, fieldInfo) {
120
+ return this.checkRepeatableSectionAccessInfo(
121
+ executionContext,
122
+ fieldInfo.field?.data_name || fieldInfo.preferredKey,
123
+ fieldInfo
124
+ ).level;
125
+ }
126
+
127
+ /**
128
+ * Check RepeatableSection field access and return structured access metadata.
129
+ * @param {Object} executionContext - Current execution context
130
+ * @param {string} fieldName - Field data_name being accessed
131
+ * @param {Object} fieldInfo - Field ownership information
132
+ * @returns {{
133
+ * level: 'accessible' | 'restricted' | 'not_found',
134
+ * code: 'main_form' | 'same_repeatable_section' | 'ancestor_repeatable_context' | 'different_repeatable_section' | null,
135
+ * message: string | null,
136
+ * suggestion: string | null,
137
+ * fieldInfo: Object | null,
138
+ * }}
139
+ */
140
+ checkRepeatableSectionAccessInfo(executionContext, fieldName, fieldInfo) {
97
141
  const { type: contextType, eventType, fieldName: contextFieldName } = executionContext;
98
142
 
99
143
  if (contextType === 'calculation') {
100
144
  // CalculatedField can only access fields in same RepeatableSection context
101
145
  const calculationFieldInfo = this.fieldOwnership.get(contextFieldName);
102
146
  if (!calculationFieldInfo) {
103
- return 'restricted'; // Unknown calculation field
147
+ return this.buildAccessInfo(
148
+ 'restricted',
149
+ 'different_repeatable_section',
150
+ fieldName,
151
+ fieldInfo,
152
+ executionContext
153
+ );
104
154
  }
105
155
 
106
- // Check if both fields have the same RepeatableSection ancestry
107
- return this.haveSameRepeatableSectionContext(
156
+ return this.resolveRepeatableSectionRelationship(
108
157
  calculationFieldInfo.parentPath,
109
- fieldInfo.parentPath
110
- )
111
- ? 'accessible'
112
- : 'restricted';
158
+ fieldInfo.parentPath,
159
+ fieldName,
160
+ fieldInfo,
161
+ executionContext
162
+ );
113
163
  }
114
164
 
115
165
  if (contextType === 'event') {
@@ -118,38 +168,75 @@ export class ContextResolver {
118
168
  switch (eventInfo.scope) {
119
169
  case 'main':
120
170
  // Record events can't access RepeatableSection fields
121
- return 'restricted';
171
+ return this.buildAccessInfo(
172
+ 'restricted',
173
+ 'different_repeatable_section',
174
+ fieldName,
175
+ fieldInfo,
176
+ executionContext
177
+ );
122
178
 
123
179
  case 'contextual':
124
180
  // Field events can access fields in same RepeatableSection context
125
181
  if (!contextFieldName) {
126
- return 'restricted'; // No field context
182
+ return this.buildAccessInfo(
183
+ 'restricted',
184
+ 'different_repeatable_section',
185
+ fieldName,
186
+ fieldInfo,
187
+ executionContext
188
+ );
127
189
  }
128
190
 
129
191
  const triggerFieldInfo = this.fieldOwnership.get(contextFieldName);
130
192
  if (!triggerFieldInfo) {
131
- return 'restricted'; // Unknown trigger field
193
+ return this.buildAccessInfo(
194
+ 'restricted',
195
+ 'different_repeatable_section',
196
+ fieldName,
197
+ fieldInfo,
198
+ executionContext
199
+ );
132
200
  }
133
201
 
134
- return this.haveSameRepeatableSectionContext(
202
+ return this.resolveRepeatableSectionRelationship(
135
203
  triggerFieldInfo.parentPath,
136
- fieldInfo.parentPath
137
- )
138
- ? 'accessible'
139
- : 'restricted';
204
+ fieldInfo.parentPath,
205
+ fieldName,
206
+ fieldInfo,
207
+ executionContext
208
+ );
140
209
 
141
210
  case 'repeatable':
142
211
  // RepeatableSection events - future implementation
143
212
  // For now, assume accessible (will be refined when implemented)
144
- return 'accessible';
213
+ return this.buildAccessInfo(
214
+ 'accessible',
215
+ 'same_repeatable_section',
216
+ fieldName,
217
+ fieldInfo,
218
+ executionContext
219
+ );
145
220
 
146
221
  default:
147
- return 'restricted';
222
+ return this.buildAccessInfo(
223
+ 'restricted',
224
+ 'different_repeatable_section',
225
+ fieldName,
226
+ fieldInfo,
227
+ executionContext
228
+ );
148
229
  }
149
230
  }
150
231
 
151
232
  // Unknown context type
152
- return 'restricted';
233
+ return this.buildAccessInfo(
234
+ 'restricted',
235
+ 'different_repeatable_section',
236
+ fieldName,
237
+ fieldInfo,
238
+ executionContext
239
+ );
153
240
  }
154
241
 
155
242
  /**
@@ -159,25 +246,80 @@ export class ContextResolver {
159
246
  * @returns {boolean} True if they share the same RepeatableSection context
160
247
  */
161
248
  haveSameRepeatableSectionContext(parentPath1, parentPath2) {
162
- const contextPath = parentPath1;
163
- const targetPath = parentPath2;
249
+ return this.resolveRepeatableSectionAccessCode(parentPath1, parentPath2) !== 'different_repeatable_section';
250
+ }
251
+
252
+ resolveRepeatableSectionRelationship(
253
+ contextPath,
254
+ targetPath,
255
+ fieldName,
256
+ fieldInfo,
257
+ executionContext
258
+ ) {
259
+ const code = this.resolveRepeatableSectionAccessCode(contextPath, targetPath);
260
+ const level = code === 'different_repeatable_section' ? 'restricted' : 'accessible';
261
+
262
+ return this.buildAccessInfo(level, code, fieldName, fieldInfo, executionContext);
263
+ }
164
264
 
165
- // Main form calculations can only access other main form fields
166
- if (contextPath.length === 0) {
167
- return targetPath.length === 0;
265
+ resolveRepeatableSectionAccessCode(contextPath, targetPath) {
266
+ if (targetPath.length === 0) {
267
+ return 'main_form';
168
268
  }
169
269
 
170
- // Child context can access its own fields
171
270
  if (ContextResolver.pathsEqual(contextPath, targetPath)) {
172
- return true;
271
+ return contextPath.length === 0 ? 'main_form' : 'same_repeatable_section';
173
272
  }
174
273
 
175
- // Child context can access ancestors (including main form)
176
274
  if (targetPath.length < contextPath.length && ContextResolver.isPrefix(targetPath, contextPath)) {
177
- return true;
275
+ return targetPath.length === 0 ? 'main_form' : 'ancestor_repeatable_context';
276
+ }
277
+
278
+ return 'different_repeatable_section';
279
+ }
280
+
281
+ buildAccessInfo(level, code, fieldName, fieldInfo, executionContext) {
282
+ if (level === 'accessible') {
283
+ return {
284
+ level,
285
+ code,
286
+ message: null,
287
+ suggestion: null,
288
+ fieldInfo,
289
+ };
290
+ }
291
+
292
+ return {
293
+ level,
294
+ code,
295
+ message: `Field '${fieldName}' is not accessible from current context`,
296
+ suggestion: this.buildRestrictedAccessSuggestion(executionContext, fieldName, fieldInfo, code),
297
+ fieldInfo,
298
+ };
299
+ }
300
+
301
+ buildRestrictedAccessSuggestion(executionContext, fieldName, fieldInfo, code) {
302
+ const { type: contextType, eventType } = executionContext;
303
+
304
+ if (contextType === 'calculation') {
305
+ 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(' -> ')}`;
307
+ }
178
308
  }
179
309
 
180
- return false;
310
+ if (contextType === 'event') {
311
+ const eventInfo = getEventInfo(eventType);
312
+
313
+ if (eventInfo.scope === 'main') {
314
+ return `Record-level events (like '${eventType}') can only access main form fields, not RepeatableSection fields`;
315
+ }
316
+
317
+ if (eventInfo.scope === 'contextual') {
318
+ return `Field events can only access fields in the same RepeatableSection or ancestor contexts as the triggering field`;
319
+ }
320
+ }
321
+
322
+ return 'Check the field access rules for your current context';
181
323
  }
182
324
 
183
325
  static isPrefix(prefix, fullPath) {
@@ -221,19 +363,25 @@ export class ContextResolver {
221
363
  * @returns {Object} Warning object with structured information
222
364
  */
223
365
  generateAccessWarning(executionContext, fieldName, reason) {
224
- const fieldInfo = this.fieldOwnership.get(fieldName);
225
- const suggestion = this.generateAccessSuggestion(executionContext, fieldName, fieldInfo);
366
+ const accessInfo =
367
+ reason === 'not_found'
368
+ ? this.resolveFieldAccessInfo(executionContext, fieldName)
369
+ : this.resolveFieldAccessInfo(executionContext, fieldName);
370
+ const fieldInfo = accessInfo.fieldInfo;
226
371
 
227
372
  return {
228
373
  type: 'FIELD_ACCESS_WARNING',
229
374
  fieldName,
230
375
  executionContext,
231
376
  reason,
377
+ accessCode: accessInfo.code,
232
378
  message:
233
- reason === 'not_found'
379
+ accessInfo.message ||
380
+ (reason === 'not_found'
234
381
  ? `Field '${fieldName}' does not exist in the form schema`
235
- : `Field '${fieldName}' is not accessible from current context`,
236
- suggestion,
382
+ : `Field '${fieldName}' is not accessible from current context`),
383
+ suggestion:
384
+ accessInfo.suggestion || this.generateAccessSuggestion(executionContext, fieldName, fieldInfo),
237
385
  fieldContext: fieldInfo
238
386
  ? {
239
387
  parentPath: fieldInfo.parentPath,
@@ -252,28 +400,26 @@ export class ContextResolver {
252
400
  * @returns {string} Helpful suggestion message
253
401
  */
254
402
  generateAccessSuggestion(executionContext, fieldName, fieldInfo) {
255
- if (!fieldInfo) {
256
- return `Check that field '${fieldName}' exists in the form schema`;
403
+ const accessInfo = this.resolveFieldAccessInfo(executionContext, fieldName, fieldInfo);
404
+
405
+ if (accessInfo.suggestion) {
406
+ return accessInfo.suggestion;
257
407
  }
258
408
 
259
- const { type: contextType, eventType, fieldName: contextFieldName } = executionContext;
409
+ if (accessInfo.level === 'not_found') {
410
+ return `Check that field '${fieldName}' exists in the form schema`;
411
+ }
260
412
 
261
- if (contextType === 'calculation') {
262
- if (fieldInfo.parentPath.length === 0) {
263
- return 'Main form fields are always accessible from calculations';
264
- } else {
265
- return `CalculatedField can only access fields in the same RepeatableSection. Field '${fieldName}' is in RepeatableSection: ${fieldInfo.parentPath.join(' -> ')}`;
266
- }
413
+ if (accessInfo.code === 'main_form') {
414
+ return 'Main form fields are always accessible from calculations';
267
415
  }
268
416
 
269
- if (contextType === 'event') {
270
- const eventInfo = getEventInfo(eventType);
417
+ if (accessInfo.code === 'same_repeatable_section') {
418
+ return 'Fields in the same RepeatableSection are accessible from this context';
419
+ }
271
420
 
272
- if (eventInfo.scope === 'main') {
273
- return `Record-level events (like '${eventType}') can only access main form fields, not RepeatableSection fields`;
274
- } else if (eventInfo.scope === 'contextual') {
275
- return `Field events can only access fields in the same RepeatableSection context as the triggering field`;
276
- }
421
+ if (accessInfo.code === 'ancestor_repeatable_context') {
422
+ return 'Fields in ancestor RepeatableSection contexts are accessible from this context';
277
423
  }
278
424
 
279
425
  return 'Check the field access rules for your current context';
@@ -157,6 +157,42 @@ function cloneCatalogDefinition(definition) {
157
157
  };
158
158
  }
159
159
 
160
+ function toCalculationReferenceAccess(accessInfo) {
161
+ return {
162
+ level: accessInfo.level,
163
+ code: accessInfo.code,
164
+ message: accessInfo.message,
165
+ suggestion: accessInfo.suggestion,
166
+ };
167
+ }
168
+
169
+ function toCalculationReferenceDefinition(field, resolver, executionContext) {
170
+ const accessInfo = resolver.resolveFieldAccessInfo(executionContext, field.data_name);
171
+
172
+ if (accessInfo.level === 'not_found') {
173
+ return null;
174
+ }
175
+
176
+ return {
177
+ key: typeof field.key === 'string' ? field.key : field.data_name,
178
+ reference: `$${field.data_name}`,
179
+ dataName: field.data_name,
180
+ label: typeof field.label === 'string' ? field.label : field.data_name,
181
+ type: typeof field.type === 'string' ? field.type : 'UnknownField',
182
+ description: typeof field.description === 'string' ? field.description : null,
183
+ displayStyle:
184
+ field.display &&
185
+ typeof field.display === 'object' &&
186
+ typeof field.display.style === 'string'
187
+ ? field.display.style
188
+ : typeof field.display === 'string'
189
+ ? field.display
190
+ : null,
191
+ parentPath: resolver.getFieldInfo(field.data_name)?.parentPath || [],
192
+ access: toCalculationReferenceAccess(accessInfo),
193
+ };
194
+ }
195
+
160
196
  function createIssue({ code, severity, message, symbol = null, index = null, length = null }) {
161
197
  return {
162
198
  code,
@@ -220,6 +256,12 @@ function createSchemaFilteredSecurityConfig(securityConfig) {
220
256
  * @property {string | null} description
221
257
  * @property {string | null} displayStyle
222
258
  * @property {string[]} parentPath
259
+ * @property {{
260
+ * level: 'accessible' | 'restricted',
261
+ * code: 'main_form' | 'same_repeatable_section' | 'ancestor_repeatable_context' | 'different_repeatable_section',
262
+ * message: string | null,
263
+ * suggestion: string | null,
264
+ * }} access
223
265
  *
224
266
  * @typedef {Object} CalculationExpressionIssue
225
267
  * @property {string} code
@@ -246,9 +288,14 @@ export function getCalculationBuiltinCatalog() {
246
288
  * @param {Object} options
247
289
  * @param {Object} options.schema
248
290
  * @param {string} options.fieldDataName
291
+ * @param {boolean} [options.includeRestricted=false]
249
292
  * @returns {CalculationReferenceDefinition[]}
250
293
  */
251
- export function getCalculationReferenceCatalog({ schema, fieldDataName }) {
294
+ export function getCalculationReferenceCatalog({
295
+ schema,
296
+ fieldDataName,
297
+ includeRestricted = false,
298
+ }) {
252
299
  const form = normalizeFormSchema(schema);
253
300
  const resolver = new ContextResolver(form);
254
301
  const executionContext = buildExecutionContext(form, fieldDataName, resolver);
@@ -262,26 +309,9 @@ export function getCalculationReferenceCatalog({ schema, fieldDataName }) {
262
309
  field.type !== 'RepeatableSection' &&
263
310
  field.type !== 'BuildingPlanSection'
264
311
  )
265
- .filter(
266
- (field) => resolver.resolveFieldAccess(executionContext, field.data_name) === 'accessible'
267
- )
268
- .map((field) => ({
269
- key: typeof field.key === 'string' ? field.key : field.data_name,
270
- reference: `$${field.data_name}`,
271
- dataName: field.data_name,
272
- label: typeof field.label === 'string' ? field.label : field.data_name,
273
- type: typeof field.type === 'string' ? field.type : 'UnknownField',
274
- description: typeof field.description === 'string' ? field.description : null,
275
- displayStyle:
276
- field.display &&
277
- typeof field.display === 'object' &&
278
- typeof field.display.style === 'string'
279
- ? field.display.style
280
- : typeof field.display === 'string'
281
- ? field.display
282
- : null,
283
- parentPath: resolver.getFieldInfo(field.data_name)?.parentPath || [],
284
- }));
312
+ .map((field) => toCalculationReferenceDefinition(field, resolver, executionContext))
313
+ .filter((reference) => reference !== null)
314
+ .filter((reference) => includeRestricted || reference.access.level === 'accessible');
285
315
  }
286
316
 
287
317
  /**
@@ -458,25 +488,28 @@ export function analyzeCalculationExpression({
458
488
  referencedFields.push(reference.fieldName);
459
489
  }
460
490
 
461
- const accessLevel = resolver.resolveFieldAccess(executionContext, reference.fieldName);
462
- if (accessLevel === 'restricted') {
491
+ const accessInfo = resolver.resolveFieldAccessInfo(executionContext, reference.fieldName);
492
+ if (accessInfo.level === 'restricted') {
463
493
  addIssue(
464
494
  issues,
465
495
  issueKeys,
466
496
  createIssue({
467
497
  code: 'restricted_field_reference',
468
498
  severity: 'error',
469
- message: resolver.generateAccessSuggestion(
470
- executionContext,
471
- reference.fieldName,
472
- resolver.getFieldInfo(reference.fieldName)
473
- ),
499
+ message:
500
+ accessInfo.suggestion ||
501
+ accessInfo.message ||
502
+ resolver.generateAccessSuggestion(
503
+ executionContext,
504
+ reference.fieldName,
505
+ resolver.getFieldInfo(reference.fieldName)
506
+ ),
474
507
  symbol: reference.reference,
475
508
  index: reference.index,
476
509
  length: reference.length,
477
510
  })
478
511
  );
479
- } else if (accessLevel === 'not_found') {
512
+ } else if (accessInfo.level === 'not_found') {
480
513
  addIssue(
481
514
  issues,
482
515
  issueKeys,