form0-core 0.1.9 → 0.1.11

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 CHANGED
@@ -32,6 +32,14 @@ If you are integrating the engine directly:
32
32
  npm install form0-core
33
33
  ```
34
34
 
35
+ `form0-core` owns behavioral schema concerns such as fields, conditions, calculations,
36
+ events, and AI metadata. Applications may still attach optional top-level metadata such as
37
+ `id` (unique form identifier), `status` (publication state), `version` (schema version),
38
+ scope fields like `main_org_id`, and media or location settings. Operational counters like
39
+ `record_count` and `record_last_change_at` should stay platform-owned and be injected at
40
+ application or API boundaries, not treated as engine-authored schema. A top-level
41
+ `form.version` may still exist, but the engine does not bump or consume it.
42
+
35
43
  ## Security
36
44
 
37
45
  See `SECURITY.md` for security modes and configuration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "form0-core",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
@@ -28,9 +28,18 @@ export function evaluateCalculatedFields(
28
28
  return;
29
29
  }
30
30
 
31
+ const blockedFieldNames = blockCyclicCalculatedFields(plan, values);
32
+ const evaluableFieldNames = plan.orderedFieldNames.filter(
33
+ (fieldName) => !blockedFieldNames.has(fieldName)
34
+ );
35
+
36
+ if (evaluableFieldNames.length === 0) {
37
+ return;
38
+ }
39
+
31
40
  if (plan.hasDynamicDependencies) {
32
41
  evaluateFieldSequenceUntilStable(
33
- plan.orderedFieldNames,
42
+ evaluableFieldNames,
34
43
  plan,
35
44
  schema,
36
45
  values,
@@ -50,17 +59,6 @@ export function evaluateCalculatedFields(
50
59
  }
51
60
 
52
61
  if (component.isCyclic) {
53
- evaluateFieldSequenceUntilStable(
54
- component.fieldNames,
55
- plan,
56
- schema,
57
- values,
58
- helpers,
59
- securityConfig,
60
- resolver,
61
- warnings,
62
- runtimeDiagnostics
63
- );
64
62
  return;
65
63
  }
66
64
 
@@ -81,6 +79,24 @@ export function evaluateCalculatedFields(
81
79
  });
82
80
  }
83
81
 
82
+ function blockCyclicCalculatedFields(plan, values) {
83
+ const blockedFieldNames = new Set();
84
+
85
+ plan.cyclicComponentIds.forEach((componentId) => {
86
+ const component = plan.componentsById.get(componentId);
87
+ if (!component?.isCyclic) {
88
+ return;
89
+ }
90
+
91
+ component.fieldNames.forEach((fieldName) => {
92
+ blockedFieldNames.add(fieldName);
93
+ values[fieldName] = null;
94
+ });
95
+ });
96
+
97
+ return blockedFieldNames;
98
+ }
99
+
84
100
  function buildScopedContext(
85
101
  values,
86
102
  helpers,
@@ -198,10 +214,11 @@ function emitDependencyPlanWarnings(plan, warningSystem, runtimeDiagnostics) {
198
214
  }
199
215
 
200
216
  const fieldNames = component.fieldNames;
201
- const message = `CalculatedField dependency cycle detected: ${fieldNames.join(' -> ')}. Runtime evaluation will use bounded stabilization.`;
217
+ const message = `CalculatedField dependency cycle detected: ${fieldNames.join(' -> ')}. Runtime evaluation is disabled for the involved fields until the cycle is removed.`;
202
218
  warningSystem.emitWarning({
203
219
  type: 'calculation_dependency',
204
220
  reason: 'cyclic_dependencies',
221
+ severity: 'error',
205
222
  message,
206
223
  suggestion:
207
224
  'Remove the cycle or break it with a source field so calculated values can be evaluated deterministically.',
@@ -220,7 +237,7 @@ function emitDependencyPlanWarnings(plan, warningSystem, runtimeDiagnostics) {
220
237
  runtimeDiagnostics,
221
238
  fieldName,
222
239
  message,
223
- 'warning',
240
+ 'error',
224
241
  `cycle:${componentId}`
225
242
  );
226
243
  });
@@ -296,7 +313,7 @@ function evaluateFieldSequenceUntilStable(
296
313
  warnings,
297
314
  runtimeDiagnostics
298
315
  ) {
299
- const maxPasses = Math.max(2, plan.totalCalculatedFieldCount);
316
+ const maxPasses = Math.max(2, fieldNames.length);
300
317
  let stillChangingFieldNames = [];
301
318
 
302
319
  for (let pass = 0; pass < maxPasses; pass += 1) {
@@ -105,8 +105,235 @@ export function normalizeCalculationFormSchema(schema) {
105
105
  throw new Error('Expected a form schema or a root schema containing form.elements');
106
106
  }
107
107
 
108
+ function isEscapedCharacter(source, index) {
109
+ let backslashCount = 0;
110
+ let cursor = index - 1;
111
+
112
+ while (cursor >= 0 && source[cursor] === '\\') {
113
+ backslashCount += 1;
114
+ cursor -= 1;
115
+ }
116
+
117
+ return backslashCount % 2 === 1;
118
+ }
119
+
120
+ function skipWhitespace(source, state) {
121
+ while (state.index < source.length && /\s/.test(source[state.index])) {
122
+ state.index += 1;
123
+ }
124
+ }
125
+
126
+ function parseStaticStringLiteral(source, state) {
127
+ const quote = source[state.index];
128
+ if (quote !== "'" && quote !== '"' && quote !== '`') {
129
+ return null;
130
+ }
131
+
132
+ state.index += 1;
133
+ let value = '';
134
+
135
+ while (state.index < source.length) {
136
+ const char = source[state.index];
137
+
138
+ if (char === quote && !isEscapedCharacter(source, state.index)) {
139
+ state.index += 1;
140
+ return value;
141
+ }
142
+
143
+ if (quote === '`' && char === '$' && source[state.index + 1] === '{') {
144
+ return null;
145
+ }
146
+
147
+ if (char === '\\' && state.index + 1 < source.length) {
148
+ const escapedChar = source[state.index + 1];
149
+ const escapeMap = {
150
+ n: '\n',
151
+ r: '\r',
152
+ t: '\t',
153
+ "'": "'",
154
+ '"': '"',
155
+ '`': '`',
156
+ '\\': '\\',
157
+ };
158
+
159
+ value += escapeMap[escapedChar] ?? escapedChar;
160
+ state.index += 2;
161
+ continue;
162
+ }
163
+
164
+ value += char;
165
+ state.index += 1;
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ function parseStaticStringTerm(source, state) {
172
+ skipWhitespace(source, state);
173
+
174
+ if (source[state.index] === '(') {
175
+ state.index += 1;
176
+ const nestedValue = parseStaticStringConcatenation(source, state);
177
+ if (nestedValue === null) {
178
+ return null;
179
+ }
180
+
181
+ skipWhitespace(source, state);
182
+ if (source[state.index] !== ')') {
183
+ return null;
184
+ }
185
+
186
+ state.index += 1;
187
+ return nestedValue;
188
+ }
189
+
190
+ return parseStaticStringLiteral(source, state);
191
+ }
192
+
193
+ function parseStaticStringConcatenation(source, state) {
194
+ const firstValue = parseStaticStringTerm(source, state);
195
+ if (firstValue === null) {
196
+ return null;
197
+ }
198
+
199
+ let result = firstValue;
200
+
201
+ while (true) {
202
+ skipWhitespace(source, state);
203
+ if (source[state.index] !== '+') {
204
+ return result;
205
+ }
206
+
207
+ state.index += 1;
208
+ const nextValue = parseStaticStringTerm(source, state);
209
+ if (nextValue === null) {
210
+ return null;
211
+ }
212
+
213
+ result += nextValue;
214
+ }
215
+ }
216
+
217
+ function resolveStaticStringExpression(source) {
218
+ if (typeof source !== 'string') {
219
+ return null;
220
+ }
221
+
222
+ const state = { index: 0 };
223
+ skipWhitespace(source, state);
224
+
225
+ if (state.index >= source.length) {
226
+ return null;
227
+ }
228
+
229
+ const result = parseStaticStringConcatenation(source, state);
230
+ if (result === null) {
231
+ return null;
232
+ }
233
+
234
+ skipWhitespace(source, state);
235
+ return state.index === source.length ? result : null;
236
+ }
237
+
238
+ function extractEvalCallArguments(code) {
239
+ const source = typeof code === 'string' ? code : '';
240
+ const argumentsList = [];
241
+ let i = 0;
242
+
243
+ while (i < source.length) {
244
+ const evalMatch = source.substring(i).match(/^EVAL\s*\(/);
245
+ if (!evalMatch) {
246
+ i += 1;
247
+ continue;
248
+ }
249
+
250
+ i += evalMatch[0].length;
251
+ const startIndex = i;
252
+
253
+ let parenCount = 1;
254
+ let inSingleQuote = false;
255
+ let inDoubleQuote = false;
256
+ let inBacktick = false;
257
+
258
+ while (i < source.length && parenCount > 0) {
259
+ const char = source[i];
260
+
261
+ if (char === "'" && !inDoubleQuote && !inBacktick && !isEscapedCharacter(source, i)) {
262
+ inSingleQuote = !inSingleQuote;
263
+ } else if (
264
+ char === '"' &&
265
+ !inSingleQuote &&
266
+ !inBacktick &&
267
+ !isEscapedCharacter(source, i)
268
+ ) {
269
+ inDoubleQuote = !inDoubleQuote;
270
+ } else if (
271
+ char === '`' &&
272
+ !inSingleQuote &&
273
+ !inDoubleQuote &&
274
+ !isEscapedCharacter(source, i)
275
+ ) {
276
+ inBacktick = !inBacktick;
277
+ }
278
+
279
+ if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
280
+ if (char === '(') {
281
+ parenCount += 1;
282
+ } else if (char === ')') {
283
+ parenCount -= 1;
284
+ if (parenCount === 0) {
285
+ argumentsList.push(source.slice(startIndex, i));
286
+ }
287
+ }
288
+ }
289
+
290
+ i += 1;
291
+ }
292
+ }
293
+
294
+ return argumentsList;
295
+ }
296
+
297
+ function resolveStaticEvalFieldReference(expression) {
298
+ let currentExpression = typeof expression === 'string' ? expression : null;
299
+
300
+ for (let depth = 0; depth < 2 && currentExpression; depth += 1) {
301
+ const resolvedString = resolveStaticStringExpression(currentExpression);
302
+ if (resolvedString === null) {
303
+ return null;
304
+ }
305
+
306
+ const fieldReferenceMatch = resolvedString.match(/^\$([a-zA-Z_][a-zA-Z0-9_]*)$/);
307
+ if (fieldReferenceMatch) {
308
+ return fieldReferenceMatch[1];
309
+ }
310
+
311
+ currentExpression = resolvedString;
312
+ }
313
+
314
+ return null;
315
+ }
316
+
317
+ function extractStaticEvalFieldReferences(code) {
318
+ const fieldReferences = new Set();
319
+
320
+ extractEvalCallArguments(code).forEach((argumentSource) => {
321
+ const fieldName = resolveStaticEvalFieldReference(argumentSource);
322
+ if (fieldName) {
323
+ fieldReferences.add(fieldName);
324
+ }
325
+ });
326
+
327
+ return fieldReferences;
328
+ }
329
+
108
330
  export function hasDynamicCalculationDependencies(code) {
109
- return /\bEVAL\s*\(/.test(typeof code === 'string' ? code : '');
331
+ const evalArguments = extractEvalCallArguments(code);
332
+ if (evalArguments.length === 0) {
333
+ return false;
334
+ }
335
+
336
+ return evalArguments.some((argumentSource) => !resolveStaticEvalFieldReference(argumentSource));
110
337
  }
111
338
 
112
339
  export function removeEvalContents(code) {
@@ -174,7 +401,15 @@ export function extractStaticFieldReferenceMatches(code) {
174
401
  }
175
402
 
176
403
  export function extractStaticFieldReferences(code) {
177
- return new Set(extractStaticFieldReferenceMatches(code).map((match) => match.fieldName));
404
+ const fieldReferences = new Set(
405
+ extractStaticFieldReferenceMatches(code).map((match) => match.fieldName)
406
+ );
407
+
408
+ extractStaticEvalFieldReferences(code).forEach((fieldName) => {
409
+ fieldReferences.add(fieldName);
410
+ });
411
+
412
+ return fieldReferences;
178
413
  }
179
414
 
180
415
  export function buildCalculationExecutionContext(form, fieldDataName, resolver) {