@objectql/core 1.8.3 → 1.9.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": "@objectql/core",
3
- "version": "1.8.3",
3
+ "version": "1.9.0",
4
4
  "description": "Universal runtime engine for ObjectQL - AI-native metadata-driven ORM with validation, repository pattern, and driver orchestration",
5
5
  "keywords": [
6
6
  "objectql",
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "openai": "^4.28.0",
22
22
  "js-yaml": "^4.1.0",
23
- "@objectql/types": "1.8.3"
23
+ "@objectql/types": "1.9.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "typescript": "^5.3.0",
package/src/ai-agent.ts CHANGED
@@ -51,7 +51,7 @@ export interface GenerateAppResult {
51
51
  files: Array<{
52
52
  filename: string;
53
53
  content: string;
54
- type: 'object' | 'validation' | 'form' | 'view' | 'page' | 'menu' | 'action' | 'hook' | 'permission' | 'workflow' | 'report' | 'data' | 'application' | 'typescript' | 'test' | 'other';
54
+ type: 'object' | 'validation' | 'action' | 'hook' | 'permission' | 'workflow' | 'data' | 'application' | 'typescript' | 'test' | 'other';
55
55
  }>;
56
56
  /** Any errors encountered */
57
57
  errors?: string[];
@@ -464,8 +464,7 @@ export class ObjectQLAgent {
464
464
  const fileTypes = new Set(currentFiles.map(f => f.type));
465
465
 
466
466
  const allTypes = [
467
- 'object', 'validation', 'form', 'view', 'page',
468
- 'menu', 'action', 'hook', 'permission', 'workflow', 'report', 'data'
467
+ 'object', 'validation', 'action', 'hook', 'permission', 'workflow', 'data'
469
468
  ];
470
469
 
471
470
  const missingTypes = allTypes.filter(t => !fileTypes.has(t as any));
@@ -478,17 +477,9 @@ export class ObjectQLAgent {
478
477
  suggestions.push('Add permissions to control access');
479
478
  }
480
479
 
481
- if (!fileTypes.has('menu')) {
482
- suggestions.push('Create a menu for navigation');
483
- }
484
-
485
480
  if (!fileTypes.has('workflow') && fileTypes.has('object')) {
486
481
  suggestions.push('Add workflows for approval processes');
487
482
  }
488
-
489
- if (!fileTypes.has('report') && fileTypes.has('object')) {
490
- suggestions.push('Generate reports for analytics');
491
- }
492
483
 
493
484
  return suggestions;
494
485
  }
@@ -512,14 +503,7 @@ Follow ObjectQL metadata standards for ALL metadata types:
512
503
  - beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete
513
504
  - Workflows (*.workflow.yml): approval processes, automation
514
505
 
515
- **3. Presentation Layer:**
516
- - Pages (*.page.yml): composable UI pages with layouts
517
- - Views (*.view.yml): list views, kanban, calendar displays
518
- - Forms (*.form.yml): data entry forms with field layouts
519
- - Reports (*.report.yml): tabular, summary, matrix reports
520
- - Menus (*.menu.yml): navigation structure
521
-
522
- **4. Security Layer:**
506
+ **3. Security Layer:**
523
507
  - Permissions (*.permission.yml): access control rules
524
508
  - Application (*.application.yml): app-level configuration
525
509
 
@@ -696,7 +680,6 @@ Include:
696
680
  - 2-3 core objects with essential fields
697
681
  - Basic relationships between objects
698
682
  - Simple validation rules
699
- - At least one form and view per object
700
683
  - At least one action with TypeScript implementation
701
684
  - At least one hook with TypeScript implementation
702
685
 
@@ -708,21 +691,15 @@ Output: Provide each file separately with clear filename headers (e.g., "# filen
708
691
  Include ALL necessary metadata types WITH implementations:
709
692
  1. **Objects**: All entities with comprehensive fields
710
693
  2. **Validations**: Business rules and constraints
711
- 3. **Forms**: Create and edit forms for each object
712
- 4. **Views**: List views for browsing data
713
- 5. **Pages**: Dashboard and detail pages
714
- 6. **Menus**: Navigation structure
715
- 7. **Actions WITH TypeScript implementations**: Common operations (approve, export, etc.) - Generate BOTH .yml metadata AND .action.ts implementation files
716
- 8. **Hooks WITH TypeScript implementations**: Lifecycle triggers - Generate .hook.ts implementation files
717
- 9. **Permissions**: Basic access control
718
- 10. **Data**: Sample seed data (optional)
719
- 11. **Workflows**: Approval processes if applicable
720
- 12. **Reports**: Key reports for analytics
721
- 13. **Tests**: Generate test files (.test.ts) for actions and hooks to validate business logic
694
+ 3. **Actions WITH TypeScript implementations**: Common operations (approve, export, etc.) - Generate BOTH .yml metadata AND .action.ts implementation files
695
+ 4. **Hooks WITH TypeScript implementations**: Lifecycle triggers - Generate .hook.ts implementation files
696
+ 5. **Permissions**: Basic access control
697
+ 6. **Data**: Sample seed data (optional)
698
+ 7. **Workflows**: Approval processes if applicable
699
+ 8. **Tests**: Generate test files (.test.ts) for actions and hooks to validate business logic
722
700
 
723
701
  Consider:
724
702
  - Security and permissions from the start
725
- - User experience in form/view design
726
703
  - Business processes and workflows
727
704
  - Data integrity and validation
728
705
  - Complete TypeScript implementations for all actions and hooks
@@ -863,15 +840,10 @@ Provide feedback in the specified format.`;
863
840
  private inferFileType(filename: string): GenerateAppResult['files'][0]['type'] {
864
841
  if (filename.includes('.object.yml')) return 'object';
865
842
  if (filename.includes('.validation.yml')) return 'validation';
866
- if (filename.includes('.form.yml')) return 'form';
867
- if (filename.includes('.view.yml')) return 'view';
868
- if (filename.includes('.page.yml')) return 'page';
869
- if (filename.includes('.menu.yml')) return 'menu';
870
843
  if (filename.includes('.action.yml')) return 'action';
871
844
  if (filename.includes('.hook.yml')) return 'hook';
872
845
  if (filename.includes('.permission.yml')) return 'permission';
873
846
  if (filename.includes('.workflow.yml')) return 'workflow';
874
- if (filename.includes('.report.yml')) return 'report';
875
847
  if (filename.includes('.data.yml')) return 'data';
876
848
  if (filename.includes('.application.yml') || filename.includes('.app.yml')) return 'application';
877
849
  if (filename.includes('.action.ts') || filename.includes('.hook.ts')) return 'typescript';
@@ -0,0 +1,564 @@
1
+ /**
2
+ * Formula Engine Implementation
3
+ *
4
+ * Evaluates formula expressions defined in object metadata.
5
+ * Formulas are read-only calculated fields computed at query time.
6
+ *
7
+ * @see @objectql/types/formula for type definitions
8
+ * @see docs/spec/formula.md for complete specification
9
+ */
10
+
11
+ import {
12
+ FormulaContext,
13
+ FormulaEvaluationResult,
14
+ FormulaEvaluationOptions,
15
+ FormulaError,
16
+ FormulaErrorType,
17
+ FormulaValue,
18
+ FormulaDataType,
19
+ FormulaMetadata,
20
+ FormulaEngineConfig,
21
+ FormulaCustomFunction,
22
+ } from '@objectql/types';
23
+
24
+ /**
25
+ * Formula Engine for evaluating JavaScript-style expressions
26
+ *
27
+ * Features:
28
+ * - Field references (e.g., `quantity * unit_price`)
29
+ * - System variables (e.g., `$today`, `$current_user.id`)
30
+ * - Lookup chains (e.g., `account.owner.name`)
31
+ * - Built-in functions (Math, String, Date methods)
32
+ * - Conditional logic (ternary, if/else)
33
+ * - Safe sandbox execution
34
+ */
35
+ export class FormulaEngine {
36
+ private config: FormulaEngineConfig;
37
+ private customFunctions: Record<string, FormulaCustomFunction>;
38
+
39
+ constructor(config: FormulaEngineConfig = {}) {
40
+ this.config = {
41
+ enable_cache: config.enable_cache ?? false,
42
+ cache_ttl: config.cache_ttl ?? 300,
43
+ max_execution_time: config.max_execution_time ?? 0, // 0 means no timeout enforcement
44
+ enable_monitoring: config.enable_monitoring ?? false,
45
+ custom_functions: config.custom_functions ?? {},
46
+ sandbox: {
47
+ enabled: config.sandbox?.enabled ?? true,
48
+ allowed_globals: config.sandbox?.allowed_globals ?? ['Math', 'String', 'Number', 'Boolean', 'Date', 'Array', 'Object'],
49
+ blocked_operations: config.sandbox?.blocked_operations ?? ['eval', 'Function', 'require', 'import'],
50
+ },
51
+ };
52
+ this.customFunctions = this.config.custom_functions || {};
53
+ }
54
+
55
+ /**
56
+ * Evaluate a formula expression
57
+ *
58
+ * @param expression - The JavaScript expression to evaluate
59
+ * @param context - Runtime context with record data, system variables, user context
60
+ * @param dataType - Expected return data type
61
+ * @param options - Evaluation options
62
+ * @returns Evaluation result with value, type, success flag, and optional error
63
+ */
64
+ evaluate(
65
+ expression: string,
66
+ context: FormulaContext,
67
+ dataType: FormulaDataType,
68
+ options: FormulaEvaluationOptions = {}
69
+ ): FormulaEvaluationResult {
70
+ const startTime = Date.now();
71
+ const timeout = options.timeout ?? this.config.max_execution_time ?? 0;
72
+
73
+ try {
74
+ // Validate expression
75
+ if (!expression || expression.trim() === '') {
76
+ throw new FormulaError(
77
+ FormulaErrorType.SYNTAX_ERROR,
78
+ 'Formula expression cannot be empty',
79
+ expression
80
+ );
81
+ }
82
+
83
+ // Prepare evaluation context
84
+ const evalContext = this.buildEvaluationContext(context);
85
+
86
+ // Execute expression with timeout
87
+ const value = this.executeExpression(expression, evalContext, timeout, options);
88
+
89
+ // Validate and coerce result type
90
+ const coercedValue = this.coerceValue(value, dataType, expression);
91
+
92
+ const executionTime = Date.now() - startTime;
93
+
94
+ return {
95
+ value: coercedValue,
96
+ type: dataType,
97
+ success: true,
98
+ execution_time: executionTime,
99
+ };
100
+ } catch (error) {
101
+ const executionTime = Date.now() - startTime;
102
+
103
+ if (error instanceof FormulaError) {
104
+ return {
105
+ value: null,
106
+ type: dataType,
107
+ success: false,
108
+ error: error.message,
109
+ stack: error.stack,
110
+ execution_time: executionTime,
111
+ };
112
+ }
113
+
114
+ // Wrap unknown errors
115
+ return {
116
+ value: null,
117
+ type: dataType,
118
+ success: false,
119
+ error: error instanceof Error ? error.message : String(error),
120
+ stack: error instanceof Error ? error.stack : undefined,
121
+ execution_time: executionTime,
122
+ };
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Build the evaluation context from FormulaContext
128
+ * Creates a safe object with field values, system variables, and user context
129
+ */
130
+ private buildEvaluationContext(context: FormulaContext): Record<string, any> {
131
+ const evalContext: Record<string, any> = {};
132
+
133
+ // Add all record fields to context
134
+ for (const [key, value] of Object.entries(context.record)) {
135
+ evalContext[key] = value;
136
+ }
137
+
138
+ // Add system variables with $ prefix
139
+ evalContext.$today = context.system.today;
140
+ evalContext.$now = context.system.now;
141
+ evalContext.$year = context.system.year;
142
+ evalContext.$month = context.system.month;
143
+ evalContext.$day = context.system.day;
144
+ evalContext.$hour = context.system.hour;
145
+ evalContext.$minute = context.system.minute;
146
+ evalContext.$second = context.system.second;
147
+
148
+ // Add current user context
149
+ evalContext.$current_user = context.current_user;
150
+
151
+ // Add record context flags
152
+ evalContext.$is_new = context.is_new;
153
+ evalContext.$record_id = context.record_id;
154
+
155
+ // Add custom functions
156
+ for (const [name, func] of Object.entries(this.customFunctions)) {
157
+ evalContext[name] = func;
158
+ }
159
+
160
+ return evalContext;
161
+ }
162
+
163
+ /**
164
+ * Execute the expression in a sandboxed environment
165
+ *
166
+ * SECURITY NOTE: Uses Function constructor for dynamic evaluation.
167
+ * While we check for blocked operations, this is not a complete security sandbox.
168
+ * For production use with untrusted formulas, consider using a proper sandboxing library
169
+ * like vm2 or implementing an AST-based evaluator.
170
+ */
171
+ private executeExpression(
172
+ expression: string,
173
+ context: Record<string, any>,
174
+ timeout: number,
175
+ options: FormulaEvaluationOptions
176
+ ): FormulaValue {
177
+ // Check for blocked operations
178
+ // NOTE: This is a basic check using string matching. It will have false positives
179
+ // (e.g., a field named 'evaluation' contains 'eval') and can be bypassed
180
+ // (e.g., using this['eval'] or globalThis['eval']).
181
+ // For production security with untrusted formulas, use AST parsing or vm2.
182
+ if (this.config.sandbox?.enabled) {
183
+ const blockedOps = this.config.sandbox.blocked_operations || [];
184
+ for (const op of blockedOps) {
185
+ if (expression.includes(op)) {
186
+ throw new FormulaError(
187
+ FormulaErrorType.SECURITY_VIOLATION,
188
+ `Blocked operation detected: ${op}`,
189
+ expression
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ try {
196
+ // Create function parameters from context keys
197
+ const paramNames = Object.keys(context);
198
+ const paramValues = Object.values(context);
199
+
200
+ // Wrap expression to handle both expression-style and statement-style formulas
201
+ const wrappedExpression = this.wrapExpression(expression);
202
+
203
+ // Create and execute function
204
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
205
+ const func = new Function(...paramNames, wrappedExpression);
206
+
207
+ // Execute with timeout protection
208
+ const result = this.executeWithTimeout(func, paramValues, timeout);
209
+
210
+ return result as FormulaValue;
211
+ } catch (error) {
212
+ if (error instanceof FormulaError) {
213
+ throw error;
214
+ }
215
+
216
+ // Parse JavaScript errors
217
+ const err = error as Error;
218
+
219
+ if (error instanceof ReferenceError) {
220
+ throw new FormulaError(
221
+ FormulaErrorType.FIELD_NOT_FOUND,
222
+ `Referenced field not found: ${err.message}`,
223
+ expression,
224
+ { original_error: err.message }
225
+ );
226
+ }
227
+
228
+ if (error instanceof TypeError) {
229
+ throw new FormulaError(
230
+ FormulaErrorType.TYPE_ERROR,
231
+ `Type error in formula: ${err.message}`,
232
+ expression,
233
+ { original_error: err.message }
234
+ );
235
+ }
236
+
237
+ if (error instanceof SyntaxError) {
238
+ throw new FormulaError(
239
+ FormulaErrorType.SYNTAX_ERROR,
240
+ `Syntax error in formula: ${err.message}`,
241
+ expression,
242
+ { original_error: err.message }
243
+ );
244
+ }
245
+
246
+ throw new FormulaError(
247
+ FormulaErrorType.RUNTIME_ERROR,
248
+ `Runtime error: ${error instanceof Error ? err.message : String(error)}`,
249
+ expression,
250
+ { original_error: error instanceof Error ? err.message : String(error) }
251
+ );
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Wrap expression to handle both expression and statement styles
257
+ */
258
+ private wrapExpression(expression: string): string {
259
+ const trimmed = expression.trim();
260
+
261
+ // If it contains a return statement or is a block, use as-is
262
+ if (trimmed.includes('return ') || (trimmed.startsWith('{') && trimmed.endsWith('}'))) {
263
+ return trimmed;
264
+ }
265
+
266
+ // If it's multi-line with if/else, wrap in a function body
267
+ if (trimmed.includes('\n') || trimmed.match(/if\s*\(/)) {
268
+ return trimmed;
269
+ }
270
+
271
+ // Otherwise, treat as expression and return it
272
+ return `return (${trimmed});`;
273
+ }
274
+
275
+ /**
276
+ * Execute function with timeout protection
277
+ *
278
+ * NOTE: This synchronous implementation **cannot** pre-emptively interrupt execution.
279
+ * To avoid giving a false sense of safety, any positive finite timeout configuration
280
+ * is rejected up-front. Callers must not rely on timeout-based protection in this
281
+ * runtime; instead, formulas must be written to be fast and side-effect free.
282
+ */
283
+ private executeWithTimeout(
284
+ func: Function,
285
+ args: any[],
286
+ timeout: number
287
+ ): unknown {
288
+ // Reject any positive finite timeout to avoid misleading "protection" semantics.
289
+ if (Number.isFinite(timeout) && timeout > 0) {
290
+ throw new FormulaError(
291
+ FormulaErrorType.TIMEOUT,
292
+ 'Formula timeout enforcement is not supported for synchronous execution. ' +
293
+ 'Remove the timeout configuration or migrate to an async/isolated runtime ' +
294
+ 'that can safely interrupt long-running formulas.',
295
+ '',
296
+ { requestedTimeoutMs: timeout }
297
+ );
298
+ }
299
+
300
+ // No timeout configured (or non-positive/invalid value): execute directly.
301
+ return func(...args);
302
+ }
303
+
304
+ /**
305
+ * Coerce the result value to the expected data type
306
+ */
307
+ private coerceValue(value: unknown, dataType: FormulaDataType, expression: string): FormulaValue {
308
+ // Handle null/undefined
309
+ if (value === null || value === undefined) {
310
+ return null;
311
+ }
312
+
313
+ try {
314
+ switch (dataType) {
315
+ case 'number':
316
+ case 'currency':
317
+ case 'percent':
318
+ if (typeof value === 'number') {
319
+ // Check for division by zero result
320
+ if (!isFinite(value)) {
321
+ throw new FormulaError(
322
+ FormulaErrorType.DIVISION_BY_ZERO,
323
+ 'Formula resulted in Infinity or NaN (possible division by zero)',
324
+ expression
325
+ );
326
+ }
327
+ return value;
328
+ }
329
+ if (typeof value === 'string') {
330
+ const num = Number(value);
331
+ if (isNaN(num)) {
332
+ throw new FormulaError(
333
+ FormulaErrorType.TYPE_ERROR,
334
+ `Cannot convert "${value}" to number`,
335
+ expression
336
+ );
337
+ }
338
+ return num;
339
+ }
340
+ if (typeof value === 'boolean') {
341
+ return value ? 1 : 0;
342
+ }
343
+ throw new FormulaError(
344
+ FormulaErrorType.TYPE_ERROR,
345
+ `Expected number, got ${typeof value}`,
346
+ expression
347
+ );
348
+
349
+ case 'text':
350
+ return String(value);
351
+
352
+ case 'boolean':
353
+ return Boolean(value);
354
+
355
+ case 'date':
356
+ case 'datetime':
357
+ if (value instanceof Date) {
358
+ return value;
359
+ }
360
+ if (typeof value === 'string') {
361
+ const date = new Date(value);
362
+ if (isNaN(date.getTime())) {
363
+ throw new FormulaError(
364
+ FormulaErrorType.TYPE_ERROR,
365
+ `Cannot convert "${value}" to date`,
366
+ expression
367
+ );
368
+ }
369
+ return date;
370
+ }
371
+ if (typeof value === 'number') {
372
+ return new Date(value);
373
+ }
374
+ throw new FormulaError(
375
+ FormulaErrorType.TYPE_ERROR,
376
+ `Expected date, got ${typeof value}`,
377
+ expression
378
+ );
379
+
380
+ default:
381
+ return value as FormulaValue;
382
+ }
383
+ } catch (error) {
384
+ if (error instanceof FormulaError) {
385
+ throw error;
386
+ }
387
+ throw new FormulaError(
388
+ FormulaErrorType.TYPE_ERROR,
389
+ `Type coercion failed: ${error instanceof Error ? error.message : String(error)}`,
390
+ expression
391
+ );
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Extract metadata from a formula expression
397
+ * Analyzes the expression to determine dependencies and complexity
398
+ */
399
+ extractMetadata(
400
+ fieldName: string,
401
+ expression: string,
402
+ dataType: FormulaDataType
403
+ ): FormulaMetadata {
404
+ const dependencies: string[] = [];
405
+ const lookupChains: string[] = [];
406
+ const systemVariables: string[] = [];
407
+ const validationErrors: string[] = [];
408
+
409
+ try {
410
+ // Extract system variables (start with $)
411
+ const systemVarPattern = /\$([a-z_][a-z0-9_]*)/gi;
412
+ const systemMatches = Array.from(expression.matchAll(systemVarPattern));
413
+ for (const match of systemMatches) {
414
+ const sysVar = '$' + match[1];
415
+ if (!systemVariables.includes(sysVar)) {
416
+ systemVariables.push(sysVar);
417
+ }
418
+ }
419
+
420
+ // Extract field references (but not system variables or keywords)
421
+ const fieldPattern = /\b([a-z_][a-z0-9_]*)\b/gi;
422
+ const matches = Array.from(expression.matchAll(fieldPattern));
423
+
424
+ for (const match of matches) {
425
+ const identifier = match[1];
426
+
427
+ // Skip JavaScript keywords and built-ins
428
+ if (this.isJavaScriptKeyword(identifier)) {
429
+ continue;
430
+ }
431
+
432
+ // Field references
433
+ if (!dependencies.includes(identifier)) {
434
+ dependencies.push(identifier);
435
+ }
436
+ }
437
+
438
+ // Extract lookup chains (e.g., account.owner.name)
439
+ const lookupPattern = /\b([a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)+)\b/gi;
440
+ const lookupMatches = Array.from(expression.matchAll(lookupPattern));
441
+
442
+ for (const match of lookupMatches) {
443
+ const chain = match[1];
444
+ if (!lookupChains.includes(chain)) {
445
+ lookupChains.push(chain);
446
+ }
447
+ }
448
+
449
+ // Basic validation
450
+ if (!expression.trim()) {
451
+ validationErrors.push('Expression cannot be empty');
452
+ }
453
+
454
+ // Estimate complexity
455
+ const complexity = this.estimateComplexity(expression);
456
+
457
+ return {
458
+ field_name: fieldName,
459
+ expression,
460
+ data_type: dataType,
461
+ dependencies,
462
+ lookup_chains: lookupChains,
463
+ system_variables: systemVariables,
464
+ is_valid: validationErrors.length === 0,
465
+ validation_errors: validationErrors.length > 0 ? validationErrors : undefined,
466
+ complexity,
467
+ };
468
+ } catch (error) {
469
+ return {
470
+ field_name: fieldName,
471
+ expression,
472
+ data_type: dataType,
473
+ dependencies: [],
474
+ lookup_chains: [],
475
+ system_variables: [],
476
+ is_valid: false,
477
+ validation_errors: [
478
+ error instanceof Error ? error.message : String(error),
479
+ ],
480
+ complexity: 'simple',
481
+ };
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Check if identifier is a JavaScript keyword or built-in
487
+ */
488
+ private isJavaScriptKeyword(identifier: string): boolean {
489
+ const keywords = new Set([
490
+ 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue',
491
+ 'return', 'function', 'var', 'let', 'const', 'true', 'false', 'null',
492
+ 'undefined', 'this', 'new', 'typeof', 'instanceof', 'in', 'of',
493
+ 'Math', 'String', 'Number', 'Boolean', 'Date', 'Array', 'Object',
494
+ 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
495
+ ]);
496
+ return keywords.has(identifier);
497
+ }
498
+
499
+ /**
500
+ * Estimate formula complexity based on heuristics
501
+ */
502
+ private estimateComplexity(expression: string): 'simple' | 'medium' | 'complex' {
503
+ const lines = expression.split('\n').length;
504
+ const hasConditionals = /if\s*\(|switch\s*\(|\?/.test(expression);
505
+ const hasLoops = /for\s*\(|while\s*\(/.test(expression);
506
+ const hasLookups = /\.[a-z_][a-z0-9_]*/.test(expression);
507
+
508
+ if (lines > 20 || hasLoops) {
509
+ return 'complex';
510
+ }
511
+
512
+ if (lines > 5 || hasConditionals || hasLookups) {
513
+ return 'medium';
514
+ }
515
+
516
+ return 'simple';
517
+ }
518
+
519
+ /**
520
+ * Register a custom function for use in formulas
521
+ */
522
+ registerFunction(name: string, func: FormulaCustomFunction): void {
523
+ this.customFunctions[name] = func;
524
+ }
525
+
526
+ /**
527
+ * Validate a formula expression without executing it
528
+ *
529
+ * SECURITY NOTE: Uses Function constructor for syntax validation.
530
+ * This doesn't execute the code but creates a function object.
531
+ * For stricter validation, consider using a parser library like @babel/parser.
532
+ */
533
+ validate(expression: string): { valid: boolean; errors: string[] } {
534
+ const errors: string[] = [];
535
+
536
+ if (!expression || expression.trim() === '') {
537
+ errors.push('Expression cannot be empty');
538
+ return { valid: false, errors };
539
+ }
540
+
541
+ // Check for blocked operations
542
+ if (this.config.sandbox?.enabled) {
543
+ const blockedOps = this.config.sandbox.blocked_operations || [];
544
+ for (const op of blockedOps) {
545
+ if (expression.includes(op)) {
546
+ errors.push(`Blocked operation detected: ${op}`);
547
+ }
548
+ }
549
+ }
550
+
551
+ // Try to parse as JavaScript (basic syntax check)
552
+ try {
553
+ const wrappedExpression = this.wrapExpression(expression);
554
+ new Function('', wrappedExpression);
555
+ } catch (error) {
556
+ errors.push(`Syntax error: ${error instanceof Error ? error.message : String(error)}`);
557
+ }
558
+
559
+ return {
560
+ valid: errors.length === 0,
561
+ errors,
562
+ };
563
+ }
564
+ }
package/src/index.ts CHANGED
@@ -7,3 +7,4 @@ export * from './object';
7
7
  export * from './validator';
8
8
  export * from './util';
9
9
  export * from './ai-agent';
10
+ export * from './formula-engine';