@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,238 @@
1
+ import { DependencyGraph, DependencyGraphBuilder } from './dependency-graph';
2
+ import { DependencyExtractor } from './dependency-extractor';
3
+ import { CircularDependencyError } from './errors';
4
+
5
+ describe('DependencyExtractor', () => {
6
+ let extractor: DependencyExtractor;
7
+
8
+ beforeEach(() => {
9
+ extractor = new DependencyExtractor();
10
+ });
11
+
12
+ it('should extract simple variable references', () => {
13
+ const deps = extractor.extract('$a + $b');
14
+
15
+ expect(deps).toEqual(new Set(['a', 'b']));
16
+ });
17
+
18
+ it('should not extract context variables', () => {
19
+ const deps = extractor.extract('$a + @userId');
20
+
21
+ expect(deps).toEqual(new Set(['a']));
22
+ });
23
+
24
+ it('should extract variables from function arguments', () => {
25
+ const deps = extractor.extract('MAX($a, $b)');
26
+
27
+ expect(deps).toEqual(new Set(['a', 'b']));
28
+ });
29
+
30
+ it('should extract root of member access', () => {
31
+ const deps = extractor.extract('$product.price');
32
+
33
+ expect(deps).toEqual(new Set(['product']));
34
+ });
35
+
36
+ it('should extract root of index access', () => {
37
+ const deps = extractor.extract('$items[0].price');
38
+
39
+ expect(deps).toEqual(new Set(['items']));
40
+ });
41
+
42
+ it('should extract variables from conditionals', () => {
43
+ const deps = extractor.extract('$a > 0 ? $b : $c');
44
+
45
+ expect(deps).toEqual(new Set(['a', 'b', 'c']));
46
+ });
47
+
48
+ it('should extract variables from nested expressions', () => {
49
+ const deps = extractor.extract('($a + $b) * ($c - $d)');
50
+
51
+ expect(deps).toEqual(new Set(['a', 'b', 'c', 'd']));
52
+ });
53
+
54
+ it('should not duplicate variables', () => {
55
+ const deps = extractor.extract('$a + $a + $a');
56
+
57
+ expect(deps).toEqual(new Set(['a']));
58
+ expect(deps.size).toBe(1);
59
+ });
60
+ });
61
+
62
+ describe('DependencyGraph', () => {
63
+ let graph: DependencyGraph;
64
+
65
+ beforeEach(() => {
66
+ graph = new DependencyGraph();
67
+ });
68
+
69
+ it('should add nodes', () => {
70
+ graph.addNode('a');
71
+ graph.addNode('b');
72
+
73
+ expect(graph.nodes.has('a')).toBe(true);
74
+ expect(graph.nodes.has('b')).toBe(true);
75
+ });
76
+
77
+ it('should add edges', () => {
78
+ graph.addEdge('a', 'b'); // a depends on b
79
+
80
+ expect(graph.getDependencies('a').has('b')).toBe(true);
81
+ });
82
+
83
+ it('should find root nodes', () => {
84
+ graph.addEdge('a', 'b');
85
+ graph.addEdge('b', 'c');
86
+ graph.addNode('c');
87
+
88
+ const roots = graph.getRoots();
89
+
90
+ expect(roots).toEqual(new Set(['c']));
91
+ });
92
+
93
+ it('should find dependents', () => {
94
+ graph.addEdge('a', 'b');
95
+ graph.addEdge('c', 'b');
96
+
97
+ const dependents = graph.getDependents('b');
98
+
99
+ expect(dependents).toEqual(new Set(['a', 'c']));
100
+ });
101
+
102
+ it('should find transitive dependencies', () => {
103
+ graph.addEdge('a', 'b');
104
+ graph.addEdge('b', 'c');
105
+ graph.addEdge('c', 'd');
106
+
107
+ const deps = graph.getTransitiveDependencies('a');
108
+
109
+ expect(deps).toEqual(new Set(['b', 'c', 'd']));
110
+ });
111
+
112
+ it('should detect cycles', () => {
113
+ graph.addEdge('a', 'b');
114
+ graph.addEdge('b', 'c');
115
+ graph.addEdge('c', 'a');
116
+
117
+ expect(graph.hasCycles()).toBe(true);
118
+ });
119
+
120
+ it('should not detect cycles when none exist', () => {
121
+ graph.addEdge('a', 'b');
122
+ graph.addEdge('b', 'c');
123
+ graph.addNode('c');
124
+
125
+ expect(graph.hasCycles()).toBe(false);
126
+ });
127
+
128
+ describe('Topological Sort', () => {
129
+ it('should return nodes in dependency order', () => {
130
+ graph.addEdge('total', 'net');
131
+ graph.addEdge('total', 'tax');
132
+ graph.addEdge('net', 'gross');
133
+ graph.addEdge('net', 'discount');
134
+ graph.addEdge('tax', 'net');
135
+ graph.addEdge('discount', 'gross');
136
+ graph.addNode('gross');
137
+
138
+ const order = graph.topologicalSort();
139
+
140
+ // gross should come before discount and net
141
+ // net should come before tax and total
142
+ // discount should come before net
143
+ expect(order.indexOf('gross')).toBeLessThan(order.indexOf('discount'));
144
+ expect(order.indexOf('gross')).toBeLessThan(order.indexOf('net'));
145
+ expect(order.indexOf('discount')).toBeLessThan(order.indexOf('net'));
146
+ expect(order.indexOf('net')).toBeLessThan(order.indexOf('tax'));
147
+ expect(order.indexOf('net')).toBeLessThan(order.indexOf('total'));
148
+ expect(order.indexOf('tax')).toBeLessThan(order.indexOf('total'));
149
+ });
150
+
151
+ it('should throw on circular dependency', () => {
152
+ graph.addEdge('a', 'b');
153
+ graph.addEdge('b', 'c');
154
+ graph.addEdge('c', 'a');
155
+
156
+ expect(() => graph.topologicalSort()).toThrow(CircularDependencyError);
157
+ });
158
+
159
+ it('should include cycle information in error', () => {
160
+ graph.addEdge('a', 'b');
161
+ graph.addEdge('b', 'c');
162
+ graph.addEdge('c', 'a');
163
+
164
+ try {
165
+ graph.topologicalSort();
166
+ fail('Expected error');
167
+ } catch (error) {
168
+ expect(error).toBeInstanceOf(CircularDependencyError);
169
+ const circError = error as CircularDependencyError;
170
+ expect(circError.cycle.length).toBeGreaterThan(0);
171
+ }
172
+ });
173
+ });
174
+ });
175
+
176
+ describe('DependencyGraphBuilder', () => {
177
+ let builder: DependencyGraphBuilder;
178
+
179
+ beforeEach(() => {
180
+ builder = new DependencyGraphBuilder();
181
+ });
182
+
183
+ it('should build graph from formulas', () => {
184
+ const formulas = [
185
+ { id: 'a', expression: '$b + $c' },
186
+ { id: 'b', expression: '$c + 1' },
187
+ { id: 'c', expression: '10' },
188
+ ];
189
+
190
+ const graph = builder.build(formulas);
191
+
192
+ expect(graph.getDependencies('a')).toEqual(new Set(['b', 'c']));
193
+ expect(graph.getDependencies('b')).toEqual(new Set(['c']));
194
+ expect(graph.getDependencies('c')).toEqual(new Set());
195
+ });
196
+
197
+ it('should get evaluation order', () => {
198
+ const formulas = [
199
+ { id: 'total', expression: '$net + $tax' },
200
+ { id: 'tax', expression: '$net * 0.2' },
201
+ { id: 'net', expression: '$gross - $discount' },
202
+ { id: 'discount', expression: '$gross * 0.1' },
203
+ { id: 'gross', expression: '$price * $qty' },
204
+ ];
205
+
206
+ const order = builder.getEvaluationOrder(formulas);
207
+
208
+ expect(order.indexOf('gross')).toBeLessThan(order.indexOf('discount'));
209
+ expect(order.indexOf('gross')).toBeLessThan(order.indexOf('net'));
210
+ expect(order.indexOf('discount')).toBeLessThan(order.indexOf('net'));
211
+ expect(order.indexOf('net')).toBeLessThan(order.indexOf('tax'));
212
+ expect(order.indexOf('net')).toBeLessThan(order.indexOf('total'));
213
+ });
214
+
215
+ it('should only include formula dependencies', () => {
216
+ const formulas = [
217
+ { id: 'total', expression: '$base + $external' }, // $external is not a formula
218
+ { id: 'base', expression: '$price * $qty' }, // $price and $qty are external
219
+ ];
220
+
221
+ const graph = builder.build(formulas);
222
+
223
+ // Only 'base' should be in dependencies of 'total', not 'external'
224
+ expect(graph.getDependencies('total')).toEqual(new Set(['base']));
225
+ });
226
+
227
+ it('should use explicit dependencies if provided', () => {
228
+ const formulas = [
229
+ { id: 'a', expression: '$x + 1', dependencies: ['b', 'c'] },
230
+ { id: 'b', expression: '2' },
231
+ { id: 'c', expression: '3' },
232
+ ];
233
+
234
+ const graph = builder.build(formulas);
235
+
236
+ expect(graph.getDependencies('a')).toEqual(new Set(['b', 'c']));
237
+ });
238
+ });
@@ -0,0 +1,288 @@
1
+ import { DependencyGraph as IDependencyGraph, FormulaDefinition } from './types';
2
+ import { DependencyExtractor } from './dependency-extractor';
3
+ import { CircularDependencyError } from './errors';
4
+
5
+ export class DependencyGraph implements IDependencyGraph {
6
+ nodes: Set<string>;
7
+ edges: Map<string, Set<string>>;
8
+
9
+ constructor() {
10
+ this.nodes = new Set();
11
+ this.edges = new Map();
12
+ }
13
+
14
+ /**
15
+ * Add a node to the graph
16
+ */
17
+ addNode(id: string): void {
18
+ this.nodes.add(id);
19
+ if (!this.edges.has(id)) {
20
+ this.edges.set(id, new Set());
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Add an edge from source to target (source depends on target)
26
+ */
27
+ addEdge(source: string, target: string): void {
28
+ this.addNode(source);
29
+ this.addNode(target);
30
+ this.edges.get(source)!.add(target);
31
+ }
32
+
33
+ /**
34
+ * Check if the graph has any cycles
35
+ */
36
+ hasCycles(): boolean {
37
+ try {
38
+ this.topologicalSort();
39
+ return false;
40
+ } catch (error) {
41
+ return error instanceof CircularDependencyError;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Get all root nodes (nodes with no dependencies)
47
+ */
48
+ getRoots(): Set<string> {
49
+ const roots = new Set<string>();
50
+ for (const node of this.nodes) {
51
+ const deps = this.edges.get(node) || new Set();
52
+ if (deps.size === 0) {
53
+ roots.add(node);
54
+ }
55
+ }
56
+ return roots;
57
+ }
58
+
59
+ /**
60
+ * Get all nodes that depend on the given node
61
+ */
62
+ getDependents(nodeId: string): Set<string> {
63
+ const dependents = new Set<string>();
64
+ for (const [node, deps] of this.edges) {
65
+ if (deps.has(nodeId)) {
66
+ dependents.add(node);
67
+ }
68
+ }
69
+ return dependents;
70
+ }
71
+
72
+ /**
73
+ * Get direct dependencies of a node
74
+ */
75
+ getDependencies(nodeId: string): Set<string> {
76
+ return this.edges.get(nodeId) || new Set();
77
+ }
78
+
79
+ /**
80
+ * Get all transitive dependencies of a node
81
+ */
82
+ getTransitiveDependencies(nodeId: string): Set<string> {
83
+ const visited = new Set<string>();
84
+ const result = new Set<string>();
85
+ this.collectTransitiveDependencies(nodeId, visited, result);
86
+ result.delete(nodeId); // Don't include the node itself
87
+ return result;
88
+ }
89
+
90
+ private collectTransitiveDependencies(
91
+ nodeId: string,
92
+ visited: Set<string>,
93
+ result: Set<string>
94
+ ): void {
95
+ if (visited.has(nodeId)) {
96
+ return;
97
+ }
98
+ visited.add(nodeId);
99
+
100
+ const deps = this.edges.get(nodeId);
101
+ if (deps) {
102
+ for (const dep of deps) {
103
+ result.add(dep);
104
+ this.collectTransitiveDependencies(dep, visited, result);
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Perform topological sort using Kahn's algorithm
111
+ * Returns nodes in evaluation order (dependencies first)
112
+ * Throws CircularDependencyError if a cycle is detected
113
+ */
114
+ topologicalSort(): string[] {
115
+ // Calculate in-degree for each node
116
+ const inDegree = new Map<string, number>();
117
+ for (const node of this.nodes) {
118
+ inDegree.set(node, 0);
119
+ }
120
+
121
+ // Count incoming edges for each node
122
+ for (const [, deps] of this.edges) {
123
+ for (const dep of deps) {
124
+ if (this.nodes.has(dep)) {
125
+ // We need to count how many nodes depend on this one
126
+ // This is actually reverse - we need dependents, not dependencies
127
+ }
128
+ }
129
+ }
130
+
131
+ // Actually, we need to build a reverse graph for proper topological sort
132
+ // edges: node -> dependencies means we need to evaluate dependencies first
133
+ // So the "in-degree" should count how many times a node is depended upon
134
+
135
+ const dependents = new Map<string, Set<string>>();
136
+ for (const node of this.nodes) {
137
+ dependents.set(node, new Set());
138
+ }
139
+
140
+ for (const [node, deps] of this.edges) {
141
+ for (const dep of deps) {
142
+ if (dependents.has(dep)) {
143
+ dependents.get(dep)!.add(node);
144
+ }
145
+ }
146
+ }
147
+
148
+ // Calculate in-degree (number of dependencies)
149
+ for (const [node, deps] of this.edges) {
150
+ // Only count dependencies that are actual nodes in our graph
151
+ const validDeps = [...deps].filter(d => this.nodes.has(d));
152
+ inDegree.set(node, validDeps.length);
153
+ }
154
+
155
+ // Start with nodes that have no dependencies
156
+ const queue: string[] = [];
157
+ for (const [node, degree] of inDegree) {
158
+ if (degree === 0) {
159
+ queue.push(node);
160
+ }
161
+ }
162
+
163
+ const result: string[] = [];
164
+ const visited = new Set<string>();
165
+
166
+ while (queue.length > 0) {
167
+ const node = queue.shift()!;
168
+ if (visited.has(node)) continue;
169
+
170
+ visited.add(node);
171
+ result.push(node);
172
+
173
+ // For each node that depends on this one, decrease its in-degree
174
+ const deps = dependents.get(node) || new Set();
175
+ for (const dependent of deps) {
176
+ const newDegree = (inDegree.get(dependent) || 0) - 1;
177
+ inDegree.set(dependent, newDegree);
178
+ if (newDegree === 0 && !visited.has(dependent)) {
179
+ queue.push(dependent);
180
+ }
181
+ }
182
+ }
183
+
184
+ // Check if all nodes were visited
185
+ if (result.length !== this.nodes.size) {
186
+ // There's a cycle - find it
187
+ const cycle = this.findCycle();
188
+ throw new CircularDependencyError(cycle, [...this.nodes].filter(n => !visited.has(n)));
189
+ }
190
+
191
+ return result;
192
+ }
193
+
194
+ /**
195
+ * Find a cycle in the graph using DFS
196
+ */
197
+ private findCycle(): string[] {
198
+ const visited = new Set<string>();
199
+ const recursionStack = new Set<string>();
200
+ const path: string[] = [];
201
+
202
+ const dfs = (node: string): string[] | null => {
203
+ visited.add(node);
204
+ recursionStack.add(node);
205
+ path.push(node);
206
+
207
+ const deps = this.edges.get(node) || new Set();
208
+ for (const dep of deps) {
209
+ if (!this.nodes.has(dep)) continue;
210
+
211
+ if (!visited.has(dep)) {
212
+ const cycle = dfs(dep);
213
+ if (cycle) return cycle;
214
+ } else if (recursionStack.has(dep)) {
215
+ // Found a cycle - extract it from the path
216
+ const cycleStart = path.indexOf(dep);
217
+ const cycle = path.slice(cycleStart);
218
+ cycle.push(dep); // Close the cycle
219
+ return cycle;
220
+ }
221
+ }
222
+
223
+ path.pop();
224
+ recursionStack.delete(node);
225
+ return null;
226
+ };
227
+
228
+ for (const node of this.nodes) {
229
+ if (!visited.has(node)) {
230
+ const cycle = dfs(node);
231
+ if (cycle) return cycle;
232
+ }
233
+ }
234
+
235
+ return [];
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Build a dependency graph from formula definitions
241
+ */
242
+ export class DependencyGraphBuilder {
243
+ private extractor: DependencyExtractor;
244
+
245
+ constructor() {
246
+ this.extractor = new DependencyExtractor();
247
+ }
248
+
249
+ /**
250
+ * Build a dependency graph from formula definitions
251
+ */
252
+ build(formulas: FormulaDefinition[]): DependencyGraph {
253
+ const graph = new DependencyGraph();
254
+
255
+ // Create a map of formula IDs for quick lookup
256
+ const formulaIds = new Set(formulas.map(f => f.id));
257
+
258
+ // Add all formula IDs as nodes
259
+ for (const formula of formulas) {
260
+ graph.addNode(formula.id);
261
+ }
262
+
263
+ // Extract dependencies and add edges
264
+ for (const formula of formulas) {
265
+ const deps = formula.dependencies
266
+ ? new Set(formula.dependencies)
267
+ : this.extractor.extract(formula.expression);
268
+
269
+ for (const dep of deps) {
270
+ // Only add edges for dependencies that are other formulas
271
+ // (not external variables)
272
+ if (formulaIds.has(dep)) {
273
+ graph.addEdge(formula.id, dep);
274
+ }
275
+ }
276
+ }
277
+
278
+ return graph;
279
+ }
280
+
281
+ /**
282
+ * Get the evaluation order for a set of formulas
283
+ */
284
+ getEvaluationOrder(formulas: FormulaDefinition[]): string[] {
285
+ const graph = this.build(formulas);
286
+ return graph.topologicalSort();
287
+ }
288
+ }