@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.
- package/.claude/settings.local.json +6 -0
- package/PRD_FORMULA_ENGINE.md +1863 -0
- package/README.md +382 -0
- package/dist/decimal-utils.d.ts +180 -0
- package/dist/decimal-utils.js +355 -0
- package/dist/dependency-extractor.d.ts +20 -0
- package/dist/dependency-extractor.js +103 -0
- package/dist/dependency-graph.d.ts +60 -0
- package/dist/dependency-graph.js +252 -0
- package/dist/errors.d.ts +161 -0
- package/dist/errors.js +260 -0
- package/dist/evaluator.d.ts +51 -0
- package/dist/evaluator.js +494 -0
- package/dist/formula-engine.d.ts +79 -0
- package/dist/formula-engine.js +355 -0
- package/dist/functions.d.ts +3 -0
- package/dist/functions.js +720 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +61 -0
- package/dist/lexer.d.ts +25 -0
- package/dist/lexer.js +357 -0
- package/dist/parser.d.ts +32 -0
- package/dist/parser.js +372 -0
- package/dist/types.d.ts +228 -0
- package/dist/types.js +62 -0
- package/jest.config.js +23 -0
- package/package.json +35 -0
- package/src/decimal-utils.ts +408 -0
- package/src/dependency-extractor.ts +117 -0
- package/src/dependency-graph.test.ts +238 -0
- package/src/dependency-graph.ts +288 -0
- package/src/errors.ts +296 -0
- package/src/evaluator.ts +604 -0
- package/src/formula-engine.test.ts +660 -0
- package/src/formula-engine.ts +430 -0
- package/src/functions.ts +770 -0
- package/src/index.ts +103 -0
- package/src/lexer.test.ts +288 -0
- package/src/lexer.ts +394 -0
- package/src/parser.test.ts +349 -0
- package/src/parser.ts +449 -0
- package/src/types.ts +347 -0
- 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
|
+
}
|