@the-trybe/formula-engine 1.0.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.
Files changed (43) hide show
  1. package/.claude/settings.local.json +6 -0
  2. package/PRD_FORMULA_ENGINE.md +1863 -0
  3. package/README.md +382 -0
  4. package/dist/decimal-utils.d.ts +180 -0
  5. package/dist/decimal-utils.js +355 -0
  6. package/dist/dependency-extractor.d.ts +20 -0
  7. package/dist/dependency-extractor.js +103 -0
  8. package/dist/dependency-graph.d.ts +60 -0
  9. package/dist/dependency-graph.js +252 -0
  10. package/dist/errors.d.ts +161 -0
  11. package/dist/errors.js +260 -0
  12. package/dist/evaluator.d.ts +51 -0
  13. package/dist/evaluator.js +494 -0
  14. package/dist/formula-engine.d.ts +79 -0
  15. package/dist/formula-engine.js +355 -0
  16. package/dist/functions.d.ts +3 -0
  17. package/dist/functions.js +720 -0
  18. package/dist/index.d.ts +10 -0
  19. package/dist/index.js +61 -0
  20. package/dist/lexer.d.ts +25 -0
  21. package/dist/lexer.js +357 -0
  22. package/dist/parser.d.ts +32 -0
  23. package/dist/parser.js +372 -0
  24. package/dist/types.d.ts +228 -0
  25. package/dist/types.js +62 -0
  26. package/jest.config.js +23 -0
  27. package/package.json +35 -0
  28. package/src/decimal-utils.ts +408 -0
  29. package/src/dependency-extractor.ts +117 -0
  30. package/src/dependency-graph.test.ts +238 -0
  31. package/src/dependency-graph.ts +288 -0
  32. package/src/errors.ts +296 -0
  33. package/src/evaluator.ts +604 -0
  34. package/src/formula-engine.test.ts +660 -0
  35. package/src/formula-engine.ts +430 -0
  36. package/src/functions.ts +770 -0
  37. package/src/index.ts +103 -0
  38. package/src/lexer.test.ts +288 -0
  39. package/src/lexer.ts +394 -0
  40. package/src/parser.test.ts +349 -0
  41. package/src/parser.ts +449 -0
  42. package/src/types.ts +347 -0
  43. package/tsconfig.json +29 -0
@@ -0,0 +1,430 @@
1
+ import {
2
+ FormulaEngineConfig,
3
+ FormulaDefinition,
4
+ EvaluationContext,
5
+ EvaluationResult,
6
+ EvaluationResultSet,
7
+ ValidationResult,
8
+ FunctionDefinition,
9
+ ASTNode,
10
+ CacheStats,
11
+ DependencyGraph as IDependencyGraph,
12
+ } from './types';
13
+ import { Parser } from './parser';
14
+ import { Evaluator } from './evaluator';
15
+ import { DependencyExtractor } from './dependency-extractor';
16
+ import { DependencyGraph, DependencyGraphBuilder } from './dependency-graph';
17
+ import { DecimalUtils, Decimal } from './decimal-utils';
18
+ import { createBuiltInFunctions } from './functions';
19
+ import {
20
+ FormulaEngineError,
21
+ GeneralFormulaError,
22
+ DuplicateFormulaError,
23
+ MaxExpressionLengthError,
24
+ } from './errors';
25
+
26
+ export class FormulaEngine {
27
+ private config: FormulaEngineConfig;
28
+ private parser: Parser;
29
+ private evaluator: Evaluator;
30
+ private dependencyExtractor: DependencyExtractor;
31
+ private graphBuilder: DependencyGraphBuilder;
32
+ private decimalUtils: DecimalUtils;
33
+ private functions: Map<string, FunctionDefinition>;
34
+
35
+ // Caches
36
+ private astCache: Map<string, ASTNode> = new Map();
37
+ private dependencyCache: Map<string, Set<string>> = new Map();
38
+ private cacheHits: number = 0;
39
+ private cacheMisses: number = 0;
40
+
41
+ constructor(config?: FormulaEngineConfig) {
42
+ this.config = {
43
+ enableCache: true,
44
+ maxCacheSize: 1000,
45
+ strictMode: true,
46
+ ...config,
47
+ };
48
+
49
+ this.decimalUtils = new DecimalUtils(this.config.decimal);
50
+ this.functions = createBuiltInFunctions(this.decimalUtils);
51
+ this.parser = new Parser();
52
+ this.dependencyExtractor = new DependencyExtractor();
53
+ this.graphBuilder = new DependencyGraphBuilder();
54
+ this.evaluator = new Evaluator(this.decimalUtils, this.functions, this.config);
55
+
56
+ // Register any custom functions from config
57
+ if (this.config.functions) {
58
+ for (const fn of this.config.functions) {
59
+ this.registerFunction(fn);
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Parse an expression into an AST
66
+ */
67
+ parse(expression: string): ASTNode {
68
+ this.checkExpressionLength(expression);
69
+
70
+ if (this.config.enableCache) {
71
+ const cached = this.astCache.get(expression);
72
+ if (cached) {
73
+ this.cacheHits++;
74
+ return cached;
75
+ }
76
+ this.cacheMisses++;
77
+ }
78
+
79
+ const ast = this.parser.parse(expression);
80
+
81
+ if (this.config.enableCache) {
82
+ this.maybeEvictCache();
83
+ this.astCache.set(expression, ast);
84
+ }
85
+
86
+ return ast;
87
+ }
88
+
89
+ /**
90
+ * Extract variable dependencies from an expression
91
+ */
92
+ extractDependencies(expression: string): Set<string> {
93
+ if (this.config.enableCache) {
94
+ const cached = this.dependencyCache.get(expression);
95
+ if (cached) {
96
+ return new Set(cached);
97
+ }
98
+ }
99
+
100
+ const deps = this.dependencyExtractor.extract(expression);
101
+
102
+ if (this.config.enableCache) {
103
+ this.dependencyCache.set(expression, new Set(deps));
104
+ }
105
+
106
+ return deps;
107
+ }
108
+
109
+ /**
110
+ * Build a dependency graph from formula definitions
111
+ */
112
+ buildDependencyGraph(formulas: FormulaDefinition[]): IDependencyGraph {
113
+ return this.graphBuilder.build(formulas);
114
+ }
115
+
116
+ /**
117
+ * Get the evaluation order for formulas
118
+ */
119
+ getEvaluationOrder(formulas: FormulaDefinition[]): string[] {
120
+ return this.graphBuilder.getEvaluationOrder(formulas);
121
+ }
122
+
123
+ /**
124
+ * Validate formulas without evaluating
125
+ */
126
+ validate(formulas: FormulaDefinition[]): ValidationResult {
127
+ const errors: FormulaEngineError[] = [];
128
+ const warnings: string[] = [];
129
+
130
+ // Check for duplicate IDs
131
+ const ids = new Set<string>();
132
+ for (const formula of formulas) {
133
+ if (ids.has(formula.id)) {
134
+ errors.push(new DuplicateFormulaError(formula.id));
135
+ }
136
+ ids.add(formula.id);
137
+ }
138
+
139
+ // Try to parse all expressions
140
+ for (const formula of formulas) {
141
+ try {
142
+ this.parse(formula.expression);
143
+ } catch (error) {
144
+ if (error instanceof FormulaEngineError) {
145
+ errors.push(error);
146
+ } else {
147
+ errors.push(new GeneralFormulaError(String(error)));
148
+ }
149
+ }
150
+ }
151
+
152
+ // Build dependency graph and check for cycles
153
+ let dependencyGraph: IDependencyGraph = new DependencyGraph();
154
+ let evaluationOrder: string[] = [];
155
+
156
+ try {
157
+ dependencyGraph = this.buildDependencyGraph(formulas);
158
+ evaluationOrder = dependencyGraph.topologicalSort();
159
+ } catch (error) {
160
+ if (error instanceof FormulaEngineError) {
161
+ errors.push(error);
162
+ }
163
+ }
164
+
165
+ return {
166
+ valid: errors.length === 0,
167
+ errors,
168
+ warnings,
169
+ dependencyGraph,
170
+ evaluationOrder,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Evaluate a single expression
176
+ */
177
+ evaluate(expression: string, context: EvaluationContext): EvaluationResult {
178
+ this.checkExpressionLength(expression);
179
+ const normalizedContext = this.normalizeContext(context);
180
+ return this.evaluator.evaluate(expression, normalizedContext);
181
+ }
182
+
183
+ /**
184
+ * Evaluate all formulas in dependency order
185
+ */
186
+ evaluateAll(
187
+ formulas: FormulaDefinition[],
188
+ context: EvaluationContext
189
+ ): EvaluationResultSet {
190
+ const startTime = Date.now();
191
+ const results = new Map<string, EvaluationResult>();
192
+ const errors: Error[] = [];
193
+
194
+ // Get evaluation order
195
+ let evaluationOrder: string[];
196
+ try {
197
+ evaluationOrder = this.getEvaluationOrder(formulas);
198
+ } catch (error) {
199
+ return {
200
+ results,
201
+ success: false,
202
+ errors: [error as Error],
203
+ totalExecutionTimeMs: Date.now() - startTime,
204
+ evaluationOrder: [],
205
+ };
206
+ }
207
+
208
+ // Create a map of formulas by ID
209
+ const formulaMap = new Map<string, FormulaDefinition>();
210
+ for (const formula of formulas) {
211
+ formulaMap.set(formula.id, formula);
212
+ }
213
+
214
+ // Evaluate in order, merging results into context
215
+ const workingContext: EvaluationContext = this.normalizeContext(context);
216
+
217
+ for (const formulaId of evaluationOrder) {
218
+ const formula = formulaMap.get(formulaId);
219
+ if (!formula) continue;
220
+
221
+ try {
222
+ const result = this.evaluator.evaluate(formula.expression, workingContext);
223
+
224
+ // Apply rounding if configured
225
+ let value = result.value;
226
+ if (formula.rounding && this.isDecimal(value)) {
227
+ value = this.applyRounding(value as Decimal, formula.rounding);
228
+ }
229
+
230
+ // Handle errors based on formula config
231
+ if (!result.success && formula.onError) {
232
+ value = this.handleError(formula, result.error);
233
+ }
234
+
235
+ results.set(formulaId, {
236
+ ...result,
237
+ value,
238
+ });
239
+
240
+ // Merge result into context for subsequent formulas
241
+ workingContext.variables[formulaId] = value;
242
+
243
+ if (!result.success) {
244
+ errors.push(result.error!);
245
+ }
246
+ } catch (error) {
247
+ const evalResult: EvaluationResult = {
248
+ value: null,
249
+ success: false,
250
+ error: error as Error,
251
+ executionTimeMs: 0,
252
+ accessedVariables: new Set(),
253
+ };
254
+
255
+ // Handle error based on formula config
256
+ if (formula.onError) {
257
+ evalResult.value = this.handleError(formula, error as Error);
258
+ }
259
+
260
+ results.set(formulaId, evalResult);
261
+ errors.push(error as Error);
262
+
263
+ // Still add to context (as null or default value) so dependent formulas can proceed
264
+ workingContext.variables[formulaId] = evalResult.value;
265
+ }
266
+ }
267
+
268
+ return {
269
+ results,
270
+ success: errors.length === 0,
271
+ errors,
272
+ totalExecutionTimeMs: Date.now() - startTime,
273
+ evaluationOrder,
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Register a custom function
279
+ */
280
+ registerFunction(definition: FunctionDefinition): void {
281
+ const name = definition.name.toUpperCase();
282
+ this.functions.set(name, definition);
283
+ }
284
+
285
+ /**
286
+ * Register multiple custom functions
287
+ */
288
+ registerFunctions(definitions: FunctionDefinition[]): void {
289
+ for (const definition of definitions) {
290
+ this.registerFunction(definition);
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Get registered function names
296
+ */
297
+ getRegisteredFunctions(): string[] {
298
+ return Array.from(this.functions.keys());
299
+ }
300
+
301
+ /**
302
+ * Clear the AST cache
303
+ */
304
+ clearCache(): void {
305
+ this.astCache.clear();
306
+ this.dependencyCache.clear();
307
+ this.cacheHits = 0;
308
+ this.cacheMisses = 0;
309
+ }
310
+
311
+ /**
312
+ * Get cache statistics
313
+ */
314
+ getCacheStats(): CacheStats {
315
+ const total = this.cacheHits + this.cacheMisses;
316
+ return {
317
+ size: this.astCache.size,
318
+ hits: this.cacheHits,
319
+ misses: this.cacheMisses,
320
+ hitRate: total > 0 ? this.cacheHits / total : 0,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Get the decimal utilities instance
326
+ */
327
+ getDecimalUtils(): DecimalUtils {
328
+ return this.decimalUtils;
329
+ }
330
+
331
+ /**
332
+ * Create a Decimal from a value
333
+ */
334
+ createDecimal(value: string | number): Decimal {
335
+ return this.decimalUtils.from(value);
336
+ }
337
+
338
+ // ============================================================================
339
+ // Private methods
340
+ // ============================================================================
341
+
342
+ private checkExpressionLength(expression: string): void {
343
+ const maxLength = this.config.security?.maxExpressionLength ?? 10000;
344
+ if (expression.length > maxLength) {
345
+ throw new MaxExpressionLengthError(expression.length, maxLength);
346
+ }
347
+ }
348
+
349
+ private normalizeContext(context: EvaluationContext): EvaluationContext {
350
+ // Deep copy and convert numeric values to Decimal if autoConvertFloats is enabled
351
+ const autoConvert = this.config.decimal?.autoConvertFloats ?? true;
352
+
353
+ if (!autoConvert) {
354
+ return { ...context };
355
+ }
356
+
357
+ const normalizedVariables: Record<string, unknown> = {};
358
+ for (const [key, value] of Object.entries(context.variables)) {
359
+ normalizedVariables[key] = this.convertValue(value);
360
+ }
361
+
362
+ return {
363
+ ...context,
364
+ variables: normalizedVariables,
365
+ };
366
+ }
367
+
368
+ private convertValue(value: unknown): unknown {
369
+ if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
370
+ return this.decimalUtils.from(value);
371
+ }
372
+ if (Array.isArray(value)) {
373
+ return value.map(v => this.convertValue(v));
374
+ }
375
+ if (value !== null && typeof value === 'object') {
376
+ const converted: Record<string, unknown> = {};
377
+ for (const [k, v] of Object.entries(value)) {
378
+ converted[k] = this.convertValue(v);
379
+ }
380
+ return converted;
381
+ }
382
+ return value;
383
+ }
384
+
385
+ private isDecimal(value: unknown): boolean {
386
+ return value instanceof Decimal;
387
+ }
388
+
389
+ private applyRounding(
390
+ value: Decimal,
391
+ config: { mode: string; precision: number }
392
+ ): Decimal {
393
+ if (config.mode === 'NONE') {
394
+ return value;
395
+ }
396
+ return this.decimalUtils.round(value, config.precision, config.mode as any);
397
+ }
398
+
399
+ private handleError(formula: FormulaDefinition, _error?: Error): unknown {
400
+ const behavior = formula.onError;
401
+ if (!behavior) return null;
402
+
403
+ switch (behavior.type) {
404
+ case 'NULL':
405
+ return null;
406
+ case 'ZERO':
407
+ return this.decimalUtils.zero();
408
+ case 'DEFAULT':
409
+ return behavior.defaultValue ?? formula.defaultValue ?? null;
410
+ case 'SKIP':
411
+ return undefined;
412
+ case 'THROW':
413
+ default:
414
+ throw _error;
415
+ }
416
+ }
417
+
418
+ private maybeEvictCache(): void {
419
+ const maxSize = this.config.maxCacheSize ?? 1000;
420
+ if (this.astCache.size >= maxSize) {
421
+ // Simple FIFO eviction - remove first 10% of entries
422
+ const toRemove = Math.ceil(maxSize * 0.1);
423
+ const keys = Array.from(this.astCache.keys()).slice(0, toRemove);
424
+ for (const key of keys) {
425
+ this.astCache.delete(key);
426
+ this.dependencyCache.delete(key);
427
+ }
428
+ }
429
+ }
430
+ }