elsabro 5.2.0 → 6.0.1
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/agents/elsabro-executor.md +25 -0
- package/agents/elsabro-planner.md +10 -0
- package/bin/install.js +8 -4
- package/commands/elsabro/execute.md +156 -3
- package/commands/elsabro/plan.md +32 -0
- package/commands/elsabro/quick.md +21 -0
- package/commands/elsabro/verify-work.md +29 -0
- package/flow-engine/package.json +13 -0
- package/flow-engine/src/checkpoint.js +112 -0
- package/flow-engine/src/executors.js +406 -0
- package/flow-engine/src/graph.js +129 -0
- package/flow-engine/src/index.js +137 -0
- package/flow-engine/src/runner.js +184 -0
- package/flow-engine/src/template.js +290 -0
- package/flow-engine/tests/executors-complex.test.js +300 -0
- package/flow-engine/tests/executors.test.js +326 -0
- package/flow-engine/tests/graph.test.js +161 -0
- package/flow-engine/tests/integration.test.js +251 -0
- package/flow-engine/tests/runner.test.js +289 -0
- package/flow-engine/tests/template.test.js +272 -0
- package/flows/development-flow.json +134 -1
- package/package.json +4 -3
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Main execution loop for the ELSABRO flow engine.
|
|
5
|
+
*
|
|
6
|
+
* Traverses the node graph sequentially, dispatching to type-specific
|
|
7
|
+
* executors, saving checkpoints after each node, and handling
|
|
8
|
+
* not_implemented nodes gracefully.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { getExecutor, checkRuntimeStatus, NotImplementedError } = require('./executors');
|
|
12
|
+
|
|
13
|
+
class RunnerError extends Error {
|
|
14
|
+
constructor(message, nodeId) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'RunnerError';
|
|
17
|
+
this.nodeId = nodeId || null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run a flow from the entry node (or from a checkpoint).
|
|
23
|
+
*
|
|
24
|
+
* @param {object} engine – FlowEngine instance (has .graph)
|
|
25
|
+
* @param {object} inputs – flow inputs (task, profile, complexity, etc.)
|
|
26
|
+
* @param {object} callbacks – all external action callbacks
|
|
27
|
+
* @param {object} [checkpoint] – optional checkpoint to resume from
|
|
28
|
+
* @returns {Promise<{ success: boolean, outputs: object, context: object, nodesVisited: string[] }>}
|
|
29
|
+
*/
|
|
30
|
+
async function runFlow(engine, inputs, callbacks, checkpoint) {
|
|
31
|
+
const graph = engine.graph;
|
|
32
|
+
if (!graph) {
|
|
33
|
+
throw new RunnerError('No flow loaded. Call engine.loadFlow() first.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Build or restore context
|
|
37
|
+
const context = checkpoint
|
|
38
|
+
? deserializeContext(checkpoint.context)
|
|
39
|
+
: {
|
|
40
|
+
inputs: inputs || {},
|
|
41
|
+
nodes: {},
|
|
42
|
+
steps: {},
|
|
43
|
+
state: {},
|
|
44
|
+
_iterations: {}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// If not resuming, set inputs
|
|
48
|
+
if (!checkpoint) {
|
|
49
|
+
context.inputs = inputs || {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let currentNodeId = checkpoint ? checkpoint.nextNode : graph.entryNode;
|
|
53
|
+
const nodesVisited = checkpoint ? (checkpoint.nodesVisited || []) : [];
|
|
54
|
+
const maxNodes = 200; // Safety: prevent infinite loops
|
|
55
|
+
let nodeCount = 0;
|
|
56
|
+
|
|
57
|
+
while (currentNodeId !== null) {
|
|
58
|
+
nodeCount++;
|
|
59
|
+
if (nodeCount > maxNodes) {
|
|
60
|
+
throw new RunnerError(
|
|
61
|
+
`Flow exceeded maximum node traversals (${maxNodes}). Possible infinite loop.`,
|
|
62
|
+
currentNodeId
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const node = graph.nodes.get(currentNodeId);
|
|
67
|
+
if (!node) {
|
|
68
|
+
throw new RunnerError(`Node "${currentNodeId}" not found in graph`, currentNodeId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 1. Notify: node starting
|
|
72
|
+
if (callbacks.onNodeStart) {
|
|
73
|
+
await callbacks.onNodeStart(node.id, node.type);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2. Check runtime_status + Execute node
|
|
77
|
+
const executor = getExecutor(node.type);
|
|
78
|
+
let result;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
checkRuntimeStatus(node);
|
|
82
|
+
result = await executor(node, context, callbacks);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Let NotImplementedError propagate cleanly
|
|
85
|
+
if (err instanceof NotImplementedError) {
|
|
86
|
+
// Save a checkpoint before stopping
|
|
87
|
+
if (callbacks.onCheckpoint) {
|
|
88
|
+
await callbacks.onCheckpoint({
|
|
89
|
+
currentNode: node.id,
|
|
90
|
+
nextNode: null,
|
|
91
|
+
context: serializeContext(context),
|
|
92
|
+
nodesVisited,
|
|
93
|
+
stoppedAt: node.id,
|
|
94
|
+
reason: err.message
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Other errors: try onError route if the node has one
|
|
101
|
+
if (node.onError) {
|
|
102
|
+
result = { next: node.onError, outputs: { error: err.message } };
|
|
103
|
+
} else {
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 4. Store outputs
|
|
109
|
+
context.nodes[node.id] = context.nodes[node.id] || {};
|
|
110
|
+
context.nodes[node.id].outputs = result.outputs || {};
|
|
111
|
+
|
|
112
|
+
nodesVisited.push(node.id);
|
|
113
|
+
|
|
114
|
+
// 5. Checkpoint
|
|
115
|
+
if (callbacks.onCheckpoint) {
|
|
116
|
+
await callbacks.onCheckpoint({
|
|
117
|
+
currentNode: node.id,
|
|
118
|
+
nextNode: result.next,
|
|
119
|
+
context: serializeContext(context),
|
|
120
|
+
nodesVisited: [...nodesVisited]
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 6. Notify: node completed
|
|
125
|
+
if (callbacks.onNodeComplete) {
|
|
126
|
+
await callbacks.onNodeComplete(node.id, result);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 7. Advance
|
|
130
|
+
currentNodeId = result.next;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Determine success based on which exit node was reached
|
|
134
|
+
const lastNodeId = nodesVisited[nodesVisited.length - 1];
|
|
135
|
+
const lastNode = graph.nodes.get(lastNodeId);
|
|
136
|
+
const isSuccess = lastNode && lastNode.status === 'success';
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
success: isSuccess,
|
|
140
|
+
outputs: context.nodes[lastNodeId]?.outputs || {},
|
|
141
|
+
context,
|
|
142
|
+
nodesVisited
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Create a JSON-serializable copy of context (strips functions, circular refs).
|
|
148
|
+
*
|
|
149
|
+
* @param {object} context
|
|
150
|
+
* @returns {object}
|
|
151
|
+
*/
|
|
152
|
+
function serializeContext(context) {
|
|
153
|
+
const safe = {};
|
|
154
|
+
|
|
155
|
+
for (const key of ['inputs', 'nodes', 'steps', 'state', '_iterations']) {
|
|
156
|
+
if (context[key] !== undefined) {
|
|
157
|
+
try {
|
|
158
|
+
safe[key] = JSON.parse(JSON.stringify(context[key]));
|
|
159
|
+
} catch {
|
|
160
|
+
safe[key] = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return safe;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Reconstruct a context from a checkpoint.
|
|
170
|
+
*
|
|
171
|
+
* @param {object} data
|
|
172
|
+
* @returns {object}
|
|
173
|
+
*/
|
|
174
|
+
function deserializeContext(data) {
|
|
175
|
+
return {
|
|
176
|
+
inputs: data.inputs || {},
|
|
177
|
+
nodes: data.nodes || {},
|
|
178
|
+
steps: data.steps || {},
|
|
179
|
+
state: data.state || {},
|
|
180
|
+
_iterations: data._iterations || {}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { runFlow, serializeContext, deserializeContext, RunnerError };
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Template expression resolver for ELSABRO flow engine.
|
|
5
|
+
*
|
|
6
|
+
* Handles 7 expression types:
|
|
7
|
+
* 1. Simple interpolation: {{inputs.task}}
|
|
8
|
+
* 2. Deep path access: {{nodes.X.outputs.Y.Z}}
|
|
9
|
+
* 3. Fallback operator: {{expr || defaultValue}}
|
|
10
|
+
* 4. Boolean expressions: {{a === 0 && b === 0}}
|
|
11
|
+
* 5. Function calls: {{functionName(args)}}
|
|
12
|
+
* 6. Filter syntax: {{value | filterName}}
|
|
13
|
+
* 7. Object/array recursion
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
class TemplateError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'TemplateError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Registry for built-in functions and filters
|
|
24
|
+
const _functions = new Map();
|
|
25
|
+
const _filters = new Map();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a built-in function that can be called from templates.
|
|
29
|
+
* @param {string} name
|
|
30
|
+
* @param {function} fn
|
|
31
|
+
*/
|
|
32
|
+
function registerFunction(name, fn) {
|
|
33
|
+
_functions.set(name, fn);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register a filter that can be piped to.
|
|
38
|
+
* @param {string} name
|
|
39
|
+
* @param {function} fn
|
|
40
|
+
*/
|
|
41
|
+
function registerFilter(name, fn) {
|
|
42
|
+
_filters.set(name, fn);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------- Built-in functions ----------
|
|
46
|
+
|
|
47
|
+
registerFunction('collectOutputs', (context, key) => {
|
|
48
|
+
const results = [];
|
|
49
|
+
if (context.nodes) {
|
|
50
|
+
for (const nodeData of Object.values(context.nodes)) {
|
|
51
|
+
const outputs = nodeData.outputs || nodeData;
|
|
52
|
+
if (outputs && outputs[key] !== undefined) {
|
|
53
|
+
const val = outputs[key];
|
|
54
|
+
if (Array.isArray(val)) {
|
|
55
|
+
results.push(...val);
|
|
56
|
+
} else {
|
|
57
|
+
results.push(val);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return results;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
registerFunction('collectDecisions', (context) => {
|
|
66
|
+
return (context.state && context.state.decisions) || [];
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
registerFunction('collectErrors', (context) => {
|
|
70
|
+
return (context.state && context.state.errors) || [];
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
registerFunction('hasCriticalIssues', (context, obj) => {
|
|
74
|
+
if (!obj) return false;
|
|
75
|
+
const json = typeof obj === 'string' ? obj : JSON.stringify(obj);
|
|
76
|
+
return json.includes('"critical"') || json.includes('"blocking"');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
registerFunction('generateSummary', (context) => {
|
|
80
|
+
const state = context.state || {};
|
|
81
|
+
const nodeCount = context.nodes ? Object.keys(context.nodes).length : 0;
|
|
82
|
+
return `Flow completed. ${nodeCount} nodes executed. Status: ${state.status || 'unknown'}`;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---------- Built-in filters ----------
|
|
86
|
+
|
|
87
|
+
registerFilter('slugify', (value) => {
|
|
88
|
+
if (typeof value !== 'string') return String(value);
|
|
89
|
+
return value
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.trim()
|
|
92
|
+
.replace(/[^\w\s-]/g, '')
|
|
93
|
+
.replace(/[\s_]+/g, '-')
|
|
94
|
+
.replace(/-+/g, '-');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------- Path resolution ----------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Walk a dotted path on an object. Returns undefined if any segment is missing.
|
|
101
|
+
* @param {object} obj
|
|
102
|
+
* @param {string} pathStr – e.g. "nodes.skill_discovery.outputs.discoveryResult"
|
|
103
|
+
* @returns {*}
|
|
104
|
+
*/
|
|
105
|
+
function resolvePath(obj, pathStr) {
|
|
106
|
+
const parts = pathStr.split('.');
|
|
107
|
+
let current = obj;
|
|
108
|
+
for (const part of parts) {
|
|
109
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
110
|
+
current = current[part];
|
|
111
|
+
}
|
|
112
|
+
return current;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------- Expression parsing ----------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Try to parse a literal value (number, boolean, string, array).
|
|
119
|
+
* Returns { value, matched } or { matched: false }.
|
|
120
|
+
*/
|
|
121
|
+
function parseLiteral(str) {
|
|
122
|
+
const trimmed = str.trim();
|
|
123
|
+
|
|
124
|
+
// Boolean
|
|
125
|
+
if (trimmed === 'true') return { value: true, matched: true };
|
|
126
|
+
if (trimmed === 'false') return { value: false, matched: true };
|
|
127
|
+
|
|
128
|
+
// Number
|
|
129
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
130
|
+
return { value: Number(trimmed), matched: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Quoted string (single or double)
|
|
134
|
+
const strMatch = trimmed.match(/^(['"])(.*)\1$/);
|
|
135
|
+
if (strMatch) {
|
|
136
|
+
return { value: strMatch[2], matched: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Empty array
|
|
140
|
+
if (trimmed === '[]') return { value: [], matched: true };
|
|
141
|
+
if (trimmed === '{}') return { value: {}, matched: true };
|
|
142
|
+
|
|
143
|
+
return { matched: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve a single expression string (the content inside {{ }}).
|
|
148
|
+
*
|
|
149
|
+
* @param {string} expr – raw expression without {{ }}
|
|
150
|
+
* @param {object} context – { inputs, nodes, steps, state }
|
|
151
|
+
* @returns {*}
|
|
152
|
+
*/
|
|
153
|
+
function resolveExpression(expr, context) {
|
|
154
|
+
const trimmed = expr.trim();
|
|
155
|
+
|
|
156
|
+
// 1. Check for filter syntax: "value | filterName"
|
|
157
|
+
// Only match pipes that are NOT inside || (fallback operator)
|
|
158
|
+
const filterMatch = trimmed.match(/^(.+?)\s*\|\s*(?!\|)(\w+)\s*$/);
|
|
159
|
+
if (filterMatch) {
|
|
160
|
+
const innerValue = resolveExpression(filterMatch[1], context);
|
|
161
|
+
const filterName = filterMatch[2];
|
|
162
|
+
const filter = _filters.get(filterName);
|
|
163
|
+
if (!filter) {
|
|
164
|
+
throw new TemplateError(`Unknown filter: "${filterName}"`);
|
|
165
|
+
}
|
|
166
|
+
return filter(innerValue);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 2. Check for fallback operator: "expr || default"
|
|
170
|
+
const fallbackMatch = trimmed.match(/^(.+?)\s*\|\|\s*(.+)$/);
|
|
171
|
+
if (fallbackMatch) {
|
|
172
|
+
const primary = resolveExpression(fallbackMatch[1], context);
|
|
173
|
+
if (primary != null && primary !== '') return primary;
|
|
174
|
+
// Try to resolve the default as an expression or literal
|
|
175
|
+
const fallbackLiteral = parseLiteral(fallbackMatch[2]);
|
|
176
|
+
if (fallbackLiteral.matched) return fallbackLiteral.value;
|
|
177
|
+
return resolveExpression(fallbackMatch[2], context);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 3. Check for negation: "!expr"
|
|
181
|
+
if (trimmed.startsWith('!')) {
|
|
182
|
+
const inner = resolveExpression(trimmed.slice(1), context);
|
|
183
|
+
return !inner;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 4. Check for boolean && expression
|
|
187
|
+
if (trimmed.includes('&&')) {
|
|
188
|
+
const parts = trimmed.split('&&').map(p => p.trim());
|
|
189
|
+
return parts.every(part => {
|
|
190
|
+
const val = resolveExpression(part, context);
|
|
191
|
+
return !!val;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 5. Check for comparison operators: ===, !==
|
|
196
|
+
const compMatch = trimmed.match(/^(.+?)\s*(===|!==)\s*(.+)$/);
|
|
197
|
+
if (compMatch) {
|
|
198
|
+
const left = resolveExpression(compMatch[1], context);
|
|
199
|
+
const right = resolveExpression(compMatch[3], context);
|
|
200
|
+
if (compMatch[2] === '===') return left === right;
|
|
201
|
+
if (compMatch[2] === '!==') return left !== right;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 6. Check for function call: "functionName(args)"
|
|
205
|
+
const funcMatch = trimmed.match(/^(\w+)\(([^)]*)\)$/);
|
|
206
|
+
if (funcMatch) {
|
|
207
|
+
const funcName = funcMatch[1];
|
|
208
|
+
const fn = _functions.get(funcName);
|
|
209
|
+
if (!fn) {
|
|
210
|
+
throw new TemplateError(`Unknown function: "${funcName}"`);
|
|
211
|
+
}
|
|
212
|
+
const argsStr = funcMatch[2].trim();
|
|
213
|
+
if (argsStr === '') {
|
|
214
|
+
return fn(context);
|
|
215
|
+
}
|
|
216
|
+
// Parse argument: could be a path reference or a quoted string
|
|
217
|
+
const argLiteral = parseLiteral(argsStr);
|
|
218
|
+
if (argLiteral.matched) {
|
|
219
|
+
return fn(context, argLiteral.value);
|
|
220
|
+
}
|
|
221
|
+
// Resolve as expression (path)
|
|
222
|
+
const argValue = resolveExpression(argsStr, context);
|
|
223
|
+
return fn(context, argValue);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 7. Literal value
|
|
227
|
+
const literal = parseLiteral(trimmed);
|
|
228
|
+
if (literal.matched) return literal.value;
|
|
229
|
+
|
|
230
|
+
// 8. Path resolution (the most common case)
|
|
231
|
+
const value = resolvePath(context, trimmed);
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Resolve all {{expression}} patterns in a template.
|
|
237
|
+
*
|
|
238
|
+
* If the template is a string, replace all {{ }} patterns.
|
|
239
|
+
* If the template is an object or array, recursively resolve all string values.
|
|
240
|
+
* If the template is not a string/object/array, return as-is.
|
|
241
|
+
*
|
|
242
|
+
* @param {*} template
|
|
243
|
+
* @param {object} context
|
|
244
|
+
* @returns {*}
|
|
245
|
+
*/
|
|
246
|
+
function resolveTemplate(template, context) {
|
|
247
|
+
if (template == null) return template;
|
|
248
|
+
|
|
249
|
+
if (typeof template === 'string') {
|
|
250
|
+
// Check if the ENTIRE string is a single expression (return raw value, not stringified)
|
|
251
|
+
const fullMatch = template.match(/^\{\{(.+)\}\}$/s);
|
|
252
|
+
if (fullMatch && !template.includes('}}{{')) {
|
|
253
|
+
return resolveExpression(fullMatch[1], context);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Otherwise, interpolate all {{ }} patterns as strings
|
|
257
|
+
return template.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
|
|
258
|
+
const value = resolveExpression(expr, context);
|
|
259
|
+
if (value === undefined || value === null) return '';
|
|
260
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
261
|
+
return String(value);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (Array.isArray(template)) {
|
|
266
|
+
return template.map(item => resolveTemplate(item, context));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (typeof template === 'object') {
|
|
270
|
+
const result = {};
|
|
271
|
+
for (const [key, value] of Object.entries(template)) {
|
|
272
|
+
result[key] = resolveTemplate(value, context);
|
|
273
|
+
}
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return template;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = {
|
|
281
|
+
resolveTemplate,
|
|
282
|
+
resolveExpression,
|
|
283
|
+
registerFunction,
|
|
284
|
+
registerFilter,
|
|
285
|
+
resolvePath,
|
|
286
|
+
TemplateError,
|
|
287
|
+
// Expose registries for testing
|
|
288
|
+
_functions,
|
|
289
|
+
_filters
|
|
290
|
+
};
|