@ak--47/dungeon-master 1.5.4 → 1.6.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/skills/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +183 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Insights formula evaluation.
|
|
3
|
+
*
|
|
4
|
+
* Mixpanel evaluates formulas in the Python API layer, not the ARB engine
|
|
5
|
+
* (`api/version_2_0/formulas/util.py`, called from `insights/api.py`).
|
|
6
|
+
* This module reimplements that layer's exact semantics:
|
|
7
|
+
*
|
|
8
|
+
* - `operate()` (util.py:25-47): if both operands are scalars, apply the
|
|
9
|
+
* op directly. Otherwise cast both to dicts — a scalar broadcasts onto
|
|
10
|
+
* the other operand's keys — and the result carries the UNION of keys;
|
|
11
|
+
* a key missing from either side is treated as 0 (util.py:38-45).
|
|
12
|
+
* Crucially the zero-pad MUTATES the left operand in place
|
|
13
|
+
* (util.py:39-41 `val1[key] = 0`), so a series dict referenced again
|
|
14
|
+
* later in the same formula carries the grown key set into that reuse
|
|
15
|
+
* — visible in output shape (extra zero-valued keys) and numerically
|
|
16
|
+
* via `^` (pow(0,0) = 1): e.g. in `A*B + A^C`, keys B added to A by
|
|
17
|
+
* the first term evaluate `0^C.get(k,0)` in the second. We replicate
|
|
18
|
+
* this within one evaluateFormula() call by cloning each series dict
|
|
19
|
+
* on entry and letting operate() pad the clones in place.
|
|
20
|
+
* NOT emulated: Python additionally shares ONE values dict across all
|
|
21
|
+
* formulas of an Insights request (insights/api.py:2890-2893
|
|
22
|
+
* `reference_metric_values`), so the pad leaks across formulas and
|
|
23
|
+
* makes later formulas' key shapes depend on formula ORDER. That
|
|
24
|
+
* cross-formula leak is an implementation artifact (order-dependent,
|
|
25
|
+
* zero-valued keys only, pow(0,0) aside) — callers here evaluate one
|
|
26
|
+
* formula per call with independent series.
|
|
27
|
+
* - Division by zero returns 0, not NaN/Infinity (util.py:81-86).
|
|
28
|
+
* - A referenced series absent from `series` evaluates as all-zeros —
|
|
29
|
+
* the `VAR` transformer defaults with `.get(arg, 0)` (util.py:65-66)
|
|
30
|
+
* and the API callsite pre-fills missing labels with 0
|
|
31
|
+
* (util.py:17-22 `get_formula_labels_filler_values` / `ZERO_FILLED_VALUES`).
|
|
32
|
+
* - Unary minus is `operate(x, -1, mul)` (util.py:90-91) — broadcasts
|
|
33
|
+
* over dicts.
|
|
34
|
+
*
|
|
35
|
+
* Grammar (formulas/grammar.lark — PEMDAS via LALR):
|
|
36
|
+
*
|
|
37
|
+
* expr: term | expr "+" term | expr "-" term
|
|
38
|
+
* term: factor | term "*" factor | term "/" factor
|
|
39
|
+
* factor: atom | "-" factor | factor "^" atom
|
|
40
|
+
* atom: NUMBER | VAR | "(" expr ")"
|
|
41
|
+
*
|
|
42
|
+
* - Power is `^`, LEFT-associative (`2^3^2` = 64, unlike Python's `**`),
|
|
43
|
+
* and its right side is an atom — `2^-3` is a parse error upstream too.
|
|
44
|
+
* - Unary minus wraps the whole factor: `-2^2` = -(2^2) = -4.
|
|
45
|
+
* - NUMBER allows readability underscores and scientific notation
|
|
46
|
+
* (util.py:59-63 strips underscores; grammar.lark NUMBER regex).
|
|
47
|
+
* - VAR is a C-style name (common.CNAME), not just single letters.
|
|
48
|
+
* - `**` is accepted here as an alias for `^` (JS convention; the ARB
|
|
49
|
+
* grammar itself only defines `^`).
|
|
50
|
+
*
|
|
51
|
+
* Recursive-descent parser, no dependency.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {number | Object<string, number>} FormulaValue
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Evaluate an Insights-style formula over named series.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} expr - e.g. `'(A/B)*100'`
|
|
62
|
+
* @param {Object<string, FormulaValue>} series - values keyed by series
|
|
63
|
+
* label; each value is a scalar or a dict keyed by segment/period.
|
|
64
|
+
* @returns {FormulaValue}
|
|
65
|
+
*/
|
|
66
|
+
export function evaluateFormula(expr, series) {
|
|
67
|
+
if (typeof expr !== 'string' || !expr.trim()) {
|
|
68
|
+
throw new Error('evaluateFormula: expr must be a non-empty string');
|
|
69
|
+
}
|
|
70
|
+
if (series == null || typeof series !== 'object' || Array.isArray(series)) {
|
|
71
|
+
throw new Error('evaluateFormula: series must be an object of named values');
|
|
72
|
+
}
|
|
73
|
+
// Clone each series dict: operate() zero-pads its left operand IN PLACE
|
|
74
|
+
// (util.py:39-41), and a var reused within the formula must see the pad
|
|
75
|
+
// — but the caller's series must not (Python leaks the pad to the shared
|
|
76
|
+
// per-request values dict, api.py:2890-2893; we confine it per call).
|
|
77
|
+
const vars = {};
|
|
78
|
+
for (const key of Object.keys(series)) {
|
|
79
|
+
const v = series[key];
|
|
80
|
+
vars[key] = isDict(v) ? { ...v } : v;
|
|
81
|
+
}
|
|
82
|
+
const tokens = tokenize(expr);
|
|
83
|
+
const parser = new Parser(tokens, vars);
|
|
84
|
+
const result = parser.parseExpr();
|
|
85
|
+
if (parser.peek() !== undefined) {
|
|
86
|
+
throw new Error(`evaluateFormula: unexpected token "${parser.peek().text}"`);
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── operate(): the util.py:25-47 broadcast/union rule ──
|
|
92
|
+
|
|
93
|
+
function operate(val1, val2, op) {
|
|
94
|
+
const d1 = isDict(val1);
|
|
95
|
+
const d2 = isDict(val2);
|
|
96
|
+
if (!d1 && !d2) return op(val1, val2);
|
|
97
|
+
|
|
98
|
+
// At least one is a dict — a scalar broadcasts onto the other's keys.
|
|
99
|
+
const a = d1 ? val1 : broadcast(val1, /** @type {Object} */ (val2));
|
|
100
|
+
const b = d2 ? val2 : broadcast(val2, /** @type {Object} */ (val1));
|
|
101
|
+
|
|
102
|
+
// Union of keys; missing side treated as 0. The pad writes INTO the left
|
|
103
|
+
// operand (util.py:39-41 `val1[key] = 0`) so a series dict reused later
|
|
104
|
+
// in the same formula sees the grown key set — numerically visible via
|
|
105
|
+
// pow(0,0) = 1. evaluateFormula clones series dicts on entry, so the
|
|
106
|
+
// mutation is confined to this evaluation.
|
|
107
|
+
for (const key of Object.keys(b)) {
|
|
108
|
+
if (!(key in a)) a[key] = 0;
|
|
109
|
+
}
|
|
110
|
+
const result = {};
|
|
111
|
+
for (const key of Object.keys(a)) {
|
|
112
|
+
result[key] = op(a[key], key in b ? b[key] : 0);
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {*} v
|
|
119
|
+
* @returns {v is Object<string, number>}
|
|
120
|
+
*/
|
|
121
|
+
function isDict(v) {
|
|
122
|
+
return v !== null && typeof v === 'object';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function broadcast(scalar, dict) {
|
|
126
|
+
const out = {};
|
|
127
|
+
for (const key of Object.keys(dict)) out[key] = scalar;
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Scalar ops (util.py FormulaTransformer) ──
|
|
132
|
+
|
|
133
|
+
const OPS = {
|
|
134
|
+
add: (a, b) => a + b,
|
|
135
|
+
sub: (a, b) => a - b,
|
|
136
|
+
mul: (a, b) => a * b,
|
|
137
|
+
pow: (a, b) => Math.pow(a, b),
|
|
138
|
+
// Graceful divide-by-zero → 0 (util.py:81-86).
|
|
139
|
+
div: (a, b) => (b === 0 ? 0 : a / b),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// ── Tokenizer ──
|
|
143
|
+
|
|
144
|
+
const NUMBER_RE = /^(?:[0-9](?:_?[0-9])*(?:\.[0-9](?:_?[0-9])*)?|\.[0-9](?:_?[0-9])*)(?:[eE][+-]?[0-9](?:_?[0-9])*)?/;
|
|
145
|
+
const VAR_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
|
|
146
|
+
|
|
147
|
+
function tokenize(expr) {
|
|
148
|
+
const tokens = [];
|
|
149
|
+
let s = expr;
|
|
150
|
+
while (s.length) {
|
|
151
|
+
const ws = s.match(/^\s+/);
|
|
152
|
+
if (ws) { s = s.slice(ws[0].length); continue; }
|
|
153
|
+
const num = s.match(NUMBER_RE);
|
|
154
|
+
if (num) {
|
|
155
|
+
// util.py:59-63 — strip readability underscores before parsing.
|
|
156
|
+
tokens.push({ type: 'number', text: num[0], value: Number(num[0].replace(/_/g, '')) });
|
|
157
|
+
s = s.slice(num[0].length);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const v = s.match(VAR_RE);
|
|
161
|
+
if (v) {
|
|
162
|
+
tokens.push({ type: 'var', text: v[0] });
|
|
163
|
+
s = s.slice(v[0].length);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (s.startsWith('**')) { // alias for the grammar's `^`
|
|
167
|
+
tokens.push({ type: 'op', text: '^' });
|
|
168
|
+
s = s.slice(2);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const ch = s[0];
|
|
172
|
+
if ('+-*/^()'.includes(ch)) {
|
|
173
|
+
tokens.push({ type: ch === '(' || ch === ')' ? 'paren' : 'op', text: ch });
|
|
174
|
+
s = s.slice(1);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`evaluateFormula: unexpected character "${ch}"`);
|
|
178
|
+
}
|
|
179
|
+
return tokens;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Recursive-descent parser mirroring grammar.lark ──
|
|
183
|
+
|
|
184
|
+
class Parser {
|
|
185
|
+
constructor(tokens, series) {
|
|
186
|
+
this.tokens = tokens;
|
|
187
|
+
this.pos = 0;
|
|
188
|
+
this.series = series;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
peek() { return this.tokens[this.pos]; }
|
|
192
|
+
|
|
193
|
+
next() {
|
|
194
|
+
const t = this.tokens[this.pos++];
|
|
195
|
+
if (!t) throw new Error('evaluateFormula: incomplete formula');
|
|
196
|
+
return t;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// expr: term | expr ("+"|"-") term
|
|
200
|
+
parseExpr() {
|
|
201
|
+
let left = this.parseTerm();
|
|
202
|
+
while (this.peek() && this.peek().type === 'op' && (this.peek().text === '+' || this.peek().text === '-')) {
|
|
203
|
+
const op = this.next().text;
|
|
204
|
+
const right = this.parseTerm();
|
|
205
|
+
left = operate(left, right, op === '+' ? OPS.add : OPS.sub);
|
|
206
|
+
}
|
|
207
|
+
return left;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// term: factor | term ("*"|"/") factor
|
|
211
|
+
parseTerm() {
|
|
212
|
+
let left = this.parseFactor();
|
|
213
|
+
while (this.peek() && this.peek().type === 'op' && (this.peek().text === '*' || this.peek().text === '/')) {
|
|
214
|
+
const op = this.next().text;
|
|
215
|
+
const right = this.parseFactor();
|
|
216
|
+
left = operate(left, right, op === '*' ? OPS.mul : OPS.div);
|
|
217
|
+
}
|
|
218
|
+
return left;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// factor: atom | "-" factor | factor "^" atom
|
|
222
|
+
// Unary minus wraps the whole factor (-2^2 = -4); pow is left-assoc
|
|
223
|
+
// with an ATOM right side (grammar.lark ?factor).
|
|
224
|
+
parseFactor() {
|
|
225
|
+
if (this.peek() && this.peek().type === 'op' && this.peek().text === '-') {
|
|
226
|
+
this.next();
|
|
227
|
+
// neg = operate(x, -1, mul) — util.py:90-91.
|
|
228
|
+
return operate(this.parseFactor(), -1, OPS.mul);
|
|
229
|
+
}
|
|
230
|
+
let left = this.parseAtom();
|
|
231
|
+
while (this.peek() && this.peek().type === 'op' && this.peek().text === '^') {
|
|
232
|
+
this.next();
|
|
233
|
+
const right = this.parseAtom();
|
|
234
|
+
left = operate(left, right, OPS.pow);
|
|
235
|
+
}
|
|
236
|
+
return left;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// atom: NUMBER | VAR | "(" expr ")"
|
|
240
|
+
parseAtom() {
|
|
241
|
+
const t = this.next();
|
|
242
|
+
if (t.type === 'number') return t.value;
|
|
243
|
+
if (t.type === 'var') {
|
|
244
|
+
// Missing series → 0 (util.py:65-66 VAR .get(arg, 0); callsite
|
|
245
|
+
// zero-fillers util.py:17-22).
|
|
246
|
+
const v = this.series[t.text];
|
|
247
|
+
return v === undefined ? 0 : v;
|
|
248
|
+
}
|
|
249
|
+
if (t.type === 'paren' && t.text === '(') {
|
|
250
|
+
const inner = this.parseExpr();
|
|
251
|
+
const close = this.next();
|
|
252
|
+
if (close.type !== 'paren' || close.text !== ')') {
|
|
253
|
+
throw new Error(`evaluateFormula: expected ")" but found "${close.text}"`);
|
|
254
|
+
}
|
|
255
|
+
return inner;
|
|
256
|
+
}
|
|
257
|
+
throw new Error(`evaluateFormula: unexpected token "${t.text}"`);
|
|
258
|
+
}
|
|
259
|
+
}
|