@crossworks/content-core 0.230.43

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 (44) hide show
  1. package/LICENSE.md +135 -0
  2. package/package.json +41 -0
  3. package/src/block-diff.test.ts +190 -0
  4. package/src/block-diff.ts +163 -0
  5. package/src/block-ids.test.ts +358 -0
  6. package/src/block-ids.ts +242 -0
  7. package/src/block-list.test.ts +241 -0
  8. package/src/block-list.ts +177 -0
  9. package/src/contacts-format.ts +260 -0
  10. package/src/doc-to-markdown.test.ts +194 -0
  11. package/src/doc-to-markdown.ts +315 -0
  12. package/src/formula-dimensions.test.ts +103 -0
  13. package/src/formula-dimensions.ts +231 -0
  14. package/src/formula-eval.ts +294 -0
  15. package/src/formula-seed.test.ts +175 -0
  16. package/src/formula-seed.ts +466 -0
  17. package/src/formula-signature.test.ts +336 -0
  18. package/src/formula-signature.ts +435 -0
  19. package/src/formula-spec.test.ts +458 -0
  20. package/src/formula-spec.ts +566 -0
  21. package/src/journal-options.test.ts +57 -0
  22. package/src/journal-options.ts +77 -0
  23. package/src/markdown-refs.test.ts +143 -0
  24. package/src/markdown-refs.ts +172 -0
  25. package/src/markdown-to-doc.test.ts +179 -0
  26. package/src/markdown-to-doc.ts +567 -0
  27. package/src/onboarding-questions.test.ts +75 -0
  28. package/src/onboarding-questions.ts +90 -0
  29. package/src/page-diff.test.ts +82 -0
  30. package/src/page-diff.ts +120 -0
  31. package/src/page-split.test.ts +141 -0
  32. package/src/page-split.ts +128 -0
  33. package/src/page-toc.test.ts +58 -0
  34. package/src/page-toc.ts +89 -0
  35. package/src/persona-bank.test.ts +67 -0
  36. package/src/persona-bank.ts +234 -0
  37. package/src/table-formula-mathjs.ts +259 -0
  38. package/src/table-formula.test.ts +157 -0
  39. package/src/table-formula.ts +496 -0
  40. package/src/table-model.test.ts +429 -0
  41. package/src/table-model.ts +870 -0
  42. package/src/thinking-tiers.ts +56 -0
  43. package/tsconfig.json +4 -0
  44. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { evalFormula } from './table-formula';
3
+ import type { Row, TableDoc } from './table-model';
4
+
5
+ const doc: TableDoc = {
6
+ columns: [
7
+ { id: 'c_qty', name: 'Qty', type: 'number' },
8
+ { id: 'c_price', name: 'Price', type: 'currency' },
9
+ { id: 'c_paid', name: 'Paid', type: 'checkbox' },
10
+ { id: 'c_name', name: 'Name', type: 'text' },
11
+ { id: 'c_calc', name: 'Calc', type: 'formula', formula: '{Qty}+1' },
12
+ ],
13
+ rows: [],
14
+ aggregates: {},
15
+ views: [],
16
+ };
17
+
18
+ const row: Row = {
19
+ id: 'r1',
20
+ cells: { c_qty: 4, c_price: 2.5, c_paid: true, c_name: 'Bolt' },
21
+ };
22
+
23
+ const ev = (f: string) => evalFormula(f, doc, row);
24
+
25
+ describe('evalFormula — arithmetic', () => {
26
+ it('multiplies and adds column refs', () => {
27
+ expect(ev('{Qty} * {Price}')).toBe(10);
28
+ expect(ev('{Qty} + 6')).toBe(10);
29
+ expect(ev('({Qty} + 1) * 2')).toBe(10);
30
+ });
31
+ it('respects precedence and unary minus', () => {
32
+ expect(ev('2 + 3 * 4')).toBe(14);
33
+ expect(ev('-{Qty} + 10')).toBe(6);
34
+ });
35
+ it('division by zero collapses to null', () => {
36
+ expect(ev('{Qty} / 0')).toBeNull();
37
+ });
38
+ });
39
+
40
+ describe('evalFormula — functions', () => {
41
+ it('ROUND, ABS, MIN, MAX, SUM', () => {
42
+ expect(ev('ROUND({Price} * {Qty} * 0.333, 2)')).toBe(3.33);
43
+ expect(ev('ABS(0 - {Qty})')).toBe(4);
44
+ expect(ev('MIN({Qty}, 2, 9)')).toBe(2);
45
+ expect(ev('MAX({Qty}, 2, 9)')).toBe(9);
46
+ expect(ev('SUM({Qty}, {Price})')).toBe(6.5);
47
+ });
48
+ it('IF with comparisons', () => {
49
+ expect(ev('IF({Qty} > 3, 100, 0)')).toBe(100);
50
+ expect(ev('IF({Paid}, 0, {Price})')).toBe(0);
51
+ expect(ev("IF({Name} == 'Bolt', 'yes', 'no')")).toBe('yes');
52
+ });
53
+ it('CONCAT and string +', () => {
54
+ expect(ev("CONCAT({Name}, '-', {Qty})")).toBe('Bolt-4');
55
+ expect(ev("{Name} + '!'")).toBe('Bolt!');
56
+ });
57
+ });
58
+
59
+ describe('evalFormula — scientific', () => {
60
+ it('SQRT, LN, LOG10, EXP, POW', () => {
61
+ expect(ev('SQRT(16)')).toBe(4);
62
+ expect(ev('LN(1)')).toBe(0);
63
+ expect(ev('LOG10(1000)')).toBe(3);
64
+ expect(ev('ROUND(EXP(1), 4)')).toBe(2.7183);
65
+ expect(ev('POW(2, 10)')).toBe(1024);
66
+ });
67
+ it('out-of-domain inputs render blank rather than a bogus number', () => {
68
+ expect(ev('SQRT(0 - 1)')).toBeNull(); // NaN
69
+ expect(ev('LN(0)')).toBeNull(); // -Infinity
70
+ });
71
+ it('PI and E are constants, not column refs', () => {
72
+ expect(ev('ROUND(PI, 5)')).toBe(3.14159);
73
+ expect(ev('ROUND(PI / 4 * 0.375 ^ 2, 4)')).toBe(0.1104); // area of a 3/8" hole
74
+ });
75
+ it('^ binds tighter than * but looser than unary minus', () => {
76
+ expect(ev('2 * 3 ^ 2')).toBe(18); // not 36
77
+ expect(ev('0 - 2 ^ 2')).toBe(-4);
78
+ expect(ev('-2 ^ 2')).toBe(-4); // -(2^2), per maths convention. Excel says +4.
79
+ });
80
+ it('accepts scientific notation and leading-dot decimals', () => {
81
+ expect(ev('1e5')).toBe(100000);
82
+ expect(ev('1.5E-6')).toBe(0.0000015);
83
+ expect(ev('6.02e+23')).toBe(6.02e23);
84
+ expect(ev('.5 * 4')).toBe(2);
85
+ // A bare E after a number is still the constant, and so fails loudly
86
+ // rather than being silently swallowed as a malformed exponent.
87
+ expect(ev('2E')).toBeNull();
88
+ });
89
+ it('compares numbers the same way it adds them', () => {
90
+ // Regression: `compare` once used bare Number() while toNum stripped
91
+ // separators, so '1,000' was 1000 to arithmetic and NaN to a comparison —
92
+ // and a NaN comparison fell through to STRING ordering.
93
+ expect(ev("'1,000' > 28.7")).toBe(true);
94
+ expect(ev("'1,000' * 1")).toBe(1000); // `+` would concatenate, by design
95
+ });
96
+ it('^ is right-associative and accepts a negative exponent', () => {
97
+ expect(ev('2 ^ 3 ^ 2')).toBe(512); // 2^(3^2), not (2^3)^2 = 64
98
+ expect(ev('2 ^ -1')).toBe(0.5);
99
+ });
100
+ });
101
+
102
+ // Acceptance: the release-rate equations from a published engineering standard
103
+ // (API RP 581 Part 3 §5.3.2/§5.3.3), which is why the scientific set exists.
104
+ // None of these were expressible before. Expected values verified independently.
105
+ describe('evalFormula — engineering formulas', () => {
106
+ const vessel: TableDoc = {
107
+ columns: [
108
+ { id: 'c_rho', name: 'Density', type: 'number' },
109
+ { id: 'c_pg', name: 'Pgauge', type: 'number' },
110
+ { id: 'c_ps', name: 'Ps', type: 'number' },
111
+ { id: 'c_mw', name: 'MW', type: 'number' },
112
+ { id: 'c_ts', name: 'Ts', type: 'number' },
113
+ { id: 'c_k', name: 'k', type: 'number' },
114
+ ],
115
+ rows: [],
116
+ aggregates: {},
117
+ views: [],
118
+ };
119
+ const r: Row = {
120
+ id: 'v1',
121
+ cells: { c_rho: 50, c_pg: 100, c_ps: 100, c_mw: 30, c_ts: 560, c_k: 1.5 },
122
+ };
123
+ const evv = (f: string) => evalFormula(f, vessel, r);
124
+
125
+ it('liquid release rate (Eq 3.3) → lb/sec', () => {
126
+ const liquid = '0.61 * 1 * {Density} * (0.11 / 12) * SQRT(2 * 32.2 * {Pgauge} / {Density})';
127
+ expect(evv(`ROUND(${liquid}, 3)`)).toBe(3.173);
128
+ });
129
+
130
+ it('vapor release rate, sonic (Eq 3.6) → lb/sec', () => {
131
+ const sonic =
132
+ '(0.61 / 1) * 0.11 * {Ps} * SQRT( ({k} * {MW} * 32.2) / (1545 * {Ts})' +
133
+ ' * (2 / ({k} + 1)) ^ (({k} + 1) / ({k} - 1)) )';
134
+ expect(evv(`ROUND(${sonic}, 4)`)).toBe(0.1572);
135
+ });
136
+
137
+ it('transition pressure (Eq 3.7) selects sonic vs subsonic', () => {
138
+ const ptrans = '14.7 * (({k} + 1) / 2) ^ ({k} / ({k} - 1))';
139
+ expect(evv(`ROUND(${ptrans}, 2)`)).toBe(28.71);
140
+ // Ps = 100 psia is above the transition pressure, so the release is sonic.
141
+ expect(evv(`IF({Ps} > ${ptrans}, 'sonic', 'subsonic')`)).toBe('sonic');
142
+ });
143
+ });
144
+
145
+ describe('evalFormula — safety', () => {
146
+ it('returns null for broken / hostile input rather than throwing', () => {
147
+ expect(ev('{Qty} *')).toBeNull();
148
+ expect(ev('process.exit(1)')).toBeNull();
149
+ expect(ev('{Unknown} + 1')).toBe(1); // unknown ref → 0
150
+ });
151
+ it('refuses to recurse into another formula column', () => {
152
+ expect(ev('{Calc} + 1')).toBe(1); // {Calc} resolves to null/0 (no formula chaining)
153
+ });
154
+ it('blank formula → null', () => {
155
+ expect(ev('')).toBeNull();
156
+ });
157
+ });
@@ -0,0 +1,496 @@
1
+ /**
2
+ * A small, safe formula evaluator for formula columns. Same-row scalar
3
+ * expressions only: arithmetic over other columns, referenced by name in
4
+ * braces — `{Qty} * {Price}`, `ROUND({Total} * 0.15, 2)`, `IF({Paid}, 0, {Due})`.
5
+ *
6
+ * Deliberately NOT JavaScript `eval`: a hand-written tokenizer + recursive
7
+ * descent parser over a tiny grammar (numbers, strings, `{refs}`, the operators
8
+ * + - * / % ^, comparisons, and a fixed function set). No identifiers reach a
9
+ * global scope, so a hostile formula can at worst return NaN.
10
+ *
11
+ * The scientific set (`^`, SQRT, POW, LN, LOG10, EXP, and the bare constants PI
12
+ * and E) exists for engineering formulas, which are rarely expressible with the
13
+ * four spreadsheet operations alone — a square root or an exponent term is the
14
+ * norm rather than the exception once a formula comes out of a standard.
15
+ *
16
+ * Cross-row math (sum/avg of a whole column) is NOT a formula — that's the
17
+ * aggregates footer (table-model.ts `computeAggregate`). Formulas see only the
18
+ * current row.
19
+ *
20
+ * Imported by table-model.ts `resolveCell`; kept dependency-free and pure so it
21
+ * runs unchanged in tool handlers, the API, and the browser.
22
+ */
23
+ import type { CellValue, Row, TableDoc } from './table-model';
24
+
25
+ type FnName =
26
+ | 'IF'
27
+ | 'ROUND'
28
+ | 'ABS'
29
+ | 'MIN'
30
+ | 'MAX'
31
+ | 'SUM'
32
+ | 'FLOOR'
33
+ | 'CEIL'
34
+ | 'CONCAT'
35
+ | 'SQRT'
36
+ | 'POW'
37
+ | 'LN'
38
+ | 'LOG10'
39
+ | 'EXP';
40
+ const FUNCTIONS = new Set<FnName>([
41
+ 'IF',
42
+ 'ROUND',
43
+ 'ABS',
44
+ 'MIN',
45
+ 'MAX',
46
+ 'SUM',
47
+ 'FLOOR',
48
+ 'CEIL',
49
+ 'CONCAT',
50
+ 'SQRT',
51
+ 'POW',
52
+ 'LN',
53
+ 'LOG10',
54
+ 'EXP',
55
+ ]);
56
+
57
+ type Token =
58
+ | { t: 'num'; v: number }
59
+ | { t: 'str'; v: string }
60
+ | { t: 'ref'; v: string }
61
+ | { t: 'ident'; v: string }
62
+ | { t: 'op'; v: string }
63
+ | { t: 'lparen' }
64
+ | { t: 'rparen' }
65
+ | { t: 'comma' };
66
+
67
+ export type EvalValue = number | string | boolean | null;
68
+
69
+ const MAX_FORMULA_LEN = 2000;
70
+
71
+ const isDigit = (ch: string | undefined): boolean => ch !== undefined && ch >= '0' && ch <= '9';
72
+
73
+ function tokenize(src: string): Token[] {
74
+ const out: Token[] = [];
75
+ let i = 0;
76
+ const n = src.length;
77
+ while (i < n) {
78
+ const c = src[i]!;
79
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
80
+ i++;
81
+ continue;
82
+ }
83
+ if (c === '{') {
84
+ const end = src.indexOf('}', i);
85
+ if (end < 0) throw new Error('unterminated {column reference}');
86
+ out.push({ t: 'ref', v: src.slice(i + 1, end).trim() });
87
+ i = end + 1;
88
+ continue;
89
+ }
90
+ if (c === "'" || c === '"') {
91
+ const end = src.indexOf(c, i + 1);
92
+ if (end < 0) throw new Error('unterminated string literal');
93
+ out.push({ t: 'str', v: src.slice(i + 1, end) });
94
+ i = end + 1;
95
+ continue;
96
+ }
97
+ // Numbers, including a leading-dot decimal (`.5`) and scientific notation
98
+ // (`1e5`, `1.5E-6`, `6.02e+23`) — which is simply how constants out of an
99
+ // engineering standard are written, and which previously tokenized as a
100
+ // number followed by the `E` constant and died as "trailing tokens",
101
+ // rendering a BLANK cell rather than an error.
102
+ if (isDigit(c) || (c === '.' && isDigit(src[i + 1]))) {
103
+ let j = i + 1;
104
+ while (j < n && /[0-9._]/.test(src[j]!)) j++;
105
+ // Only consume `e`/`E` as an exponent when real digits follow, so a bare
106
+ // `E` after a number stays the constant and fails loudly instead.
107
+ if (src[j] === 'e' || src[j] === 'E') {
108
+ let k = j + 1;
109
+ if (src[k] === '+' || src[k] === '-') k++;
110
+ if (isDigit(src[k])) {
111
+ while (k < n && isDigit(src[k])) k++;
112
+ j = k;
113
+ }
114
+ }
115
+ out.push({ t: 'num', v: Number(src.slice(i, j).replace(/_/g, '')) });
116
+ i = j;
117
+ continue;
118
+ }
119
+ if (/[A-Za-z]/.test(c)) {
120
+ let j = i + 1;
121
+ while (j < n && /[A-Za-z0-9_]/.test(src[j]!)) j++;
122
+ out.push({ t: 'ident', v: src.slice(i, j) });
123
+ i = j;
124
+ continue;
125
+ }
126
+ // Two-char comparison operators.
127
+ const two = src.slice(i, i + 2);
128
+ if (two === '>=' || two === '<=' || two === '==' || two === '!=' || two === '<>') {
129
+ out.push({ t: 'op', v: two === '<>' ? '!=' : two });
130
+ i += 2;
131
+ continue;
132
+ }
133
+ if ('+-*/%<>^'.includes(c)) {
134
+ out.push({ t: 'op', v: c });
135
+ i++;
136
+ continue;
137
+ }
138
+ if (c === '=') {
139
+ out.push({ t: 'op', v: '==' });
140
+ i++;
141
+ continue;
142
+ }
143
+ if (c === '(') {
144
+ out.push({ t: 'lparen' });
145
+ i++;
146
+ continue;
147
+ }
148
+ if (c === ')') {
149
+ out.push({ t: 'rparen' });
150
+ i++;
151
+ continue;
152
+ }
153
+ if (c === ',') {
154
+ out.push({ t: 'comma' });
155
+ i++;
156
+ continue;
157
+ }
158
+ throw new Error(`unexpected character '${c}'`);
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * How a `{braced}` reference resolves to a value. Table formulas bind refs to
165
+ * columns of the current row; the formula-spec evaluator binds them to named
166
+ * variables. Same grammar, same parser, two binding strategies — there is
167
+ * deliberately only one expression language in the codebase.
168
+ */
169
+ export type RefResolver = (name: string) => EvalValue;
170
+
171
+ class Parser {
172
+ private pos = 0;
173
+ constructor(
174
+ private toks: Token[],
175
+ private resolve: RefResolver,
176
+ ) {}
177
+
178
+ private peek(): Token | undefined {
179
+ return this.toks[this.pos];
180
+ }
181
+ private next(): Token | undefined {
182
+ return this.toks[this.pos++];
183
+ }
184
+ private expect(t: Token['t']): void {
185
+ const tok = this.next();
186
+ if (!tok || tok.t !== t) throw new Error(`expected ${t}`);
187
+ }
188
+
189
+ parse(): EvalValue {
190
+ const v = this.parseComparison();
191
+ if (this.pos < this.toks.length) throw new Error('trailing tokens');
192
+ return v;
193
+ }
194
+
195
+ // comparison: addsub (op addsub)?
196
+ private parseComparison(): EvalValue {
197
+ let left = this.parseAddSub();
198
+ const tok = this.peek();
199
+ if (tok?.t === 'op' && ['>', '<', '>=', '<=', '==', '!='].includes(tok.v)) {
200
+ this.next();
201
+ const right = this.parseAddSub();
202
+ left = compare(tok.v, left, right);
203
+ }
204
+ return left;
205
+ }
206
+
207
+ private parseAddSub(): EvalValue {
208
+ let left = this.parseMulDiv();
209
+ for (;;) {
210
+ const tok = this.peek();
211
+ if (tok?.t === 'op' && (tok.v === '+' || tok.v === '-')) {
212
+ this.next();
213
+ const right = this.parseMulDiv();
214
+ if (tok.v === '+') {
215
+ // '+' concatenates when either side is a non-numeric string.
216
+ if (typeof left === 'string' || typeof right === 'string') {
217
+ left = `${toStr(left)}${toStr(right)}`;
218
+ } else {
219
+ left = toNum(left) + toNum(right);
220
+ }
221
+ } else {
222
+ left = toNum(left) - toNum(right);
223
+ }
224
+ } else break;
225
+ }
226
+ return left;
227
+ }
228
+
229
+ private parseMulDiv(): EvalValue {
230
+ let left = this.parseUnary();
231
+ for (;;) {
232
+ const tok = this.peek();
233
+ if (tok?.t === 'op' && (tok.v === '*' || tok.v === '/' || tok.v === '%')) {
234
+ this.next();
235
+ const right = toNum(this.parseUnary());
236
+ const l = toNum(left);
237
+ left =
238
+ tok.v === '*' ? l * right : tok.v === '/' ? (right === 0 ? NaN : l / right) : l % right;
239
+ } else break;
240
+ }
241
+ return left;
242
+ }
243
+
244
+ private parseUnary(): EvalValue {
245
+ const tok = this.peek();
246
+ if (tok?.t === 'op' && tok.v === '-') {
247
+ this.next();
248
+ return -toNum(this.parseUnary());
249
+ }
250
+ if (tok?.t === 'op' && tok.v === '+') {
251
+ this.next();
252
+ return this.parseUnary();
253
+ }
254
+ return this.parsePow();
255
+ }
256
+
257
+ // Exponentiation binds tighter than * and /, AND tighter than unary minus —
258
+ // which is what makes `-2^2` parse as -(2^2) = -4, following normal
259
+ // mathematical convention. (Excel is the well-known counter-example: there
260
+ // `=-2^2` is +4. We deliberately do not copy Excel here.) Right-associative,
261
+ // so `2^3^2` is 2^9; the exponent re-enters parseUnary so `2^-1` is legal.
262
+ private parsePow(): EvalValue {
263
+ const base = this.parsePrimary();
264
+ const tok = this.peek();
265
+ if (tok?.t === 'op' && tok.v === '^') {
266
+ this.next();
267
+ return toNum(base) ** toNum(this.parseUnary());
268
+ }
269
+ return base;
270
+ }
271
+
272
+ private parsePrimary(): EvalValue {
273
+ const tok = this.next();
274
+ if (!tok) throw new Error('unexpected end of formula');
275
+ switch (tok.t) {
276
+ case 'num':
277
+ return tok.v;
278
+ case 'str':
279
+ return tok.v;
280
+ case 'ref':
281
+ return this.resolve(tok.v);
282
+ case 'lparen': {
283
+ const v = this.parseComparison();
284
+ this.expect('rparen');
285
+ return v;
286
+ }
287
+ case 'ident': {
288
+ const upper = tok.v.toUpperCase();
289
+ if (upper === 'TRUE') return true;
290
+ if (upper === 'FALSE') return false;
291
+ // Bare mathematical constants. Safe as identifiers because column
292
+ // references are always braced — `PI` can never shadow a column.
293
+ if (upper === 'PI') return Math.PI;
294
+ if (upper === 'E') return Math.E;
295
+ if (this.peek()?.t === 'lparen' && FUNCTIONS.has(upper as FnName)) {
296
+ return this.parseCall(upper as FnName);
297
+ }
298
+ throw new Error(`unknown identifier '${tok.v}'`);
299
+ }
300
+ default:
301
+ throw new Error('unexpected token');
302
+ }
303
+ }
304
+
305
+ private parseCall(name: FnName): EvalValue {
306
+ this.expect('lparen');
307
+ const args: EvalValue[] = [];
308
+ if (this.peek()?.t !== 'rparen') {
309
+ args.push(this.parseComparison());
310
+ while (this.peek()?.t === 'comma') {
311
+ this.next();
312
+ args.push(this.parseComparison());
313
+ }
314
+ }
315
+ this.expect('rparen');
316
+ return applyFn(name, args);
317
+ }
318
+ }
319
+
320
+ /**
321
+ * The `{braced}` symbols an expression references, in source order, deduped.
322
+ *
323
+ * STATIC — nothing is evaluated, so this sees every branch of an `IF()` rather
324
+ * than the one a particular set of inputs would take. That is the property
325
+ * `signatureOf` needs: "what must I supply" has to be answered before any
326
+ * value exists to supply. Reusing the tokenizer rather than a `/\{([^}]*)\}/`
327
+ * scan matters for one case — a brace inside a string literal (`'{x}'`) is
328
+ * text, not a reference, and only the tokenizer knows that.
329
+ *
330
+ * Throws on an unparseable expression, like `evalExpression`.
331
+ */
332
+ export function refsIn(src: string): string[] {
333
+ const text = (src ?? '').trim();
334
+ if (!text) return [];
335
+ if (text.length > MAX_FORMULA_LEN) throw new Error('expression too long');
336
+ const out: string[] = [];
337
+ const seen = new Set<string>();
338
+ for (const tok of tokenize(text)) {
339
+ if (tok.t === 'ref' && tok.v && !seen.has(tok.v)) {
340
+ seen.add(tok.v);
341
+ out.push(tok.v);
342
+ }
343
+ }
344
+ return out;
345
+ }
346
+
347
+ /** Binds `{refs}` to columns of one row. */
348
+ function columnResolver(doc: TableDoc, row: Row): RefResolver {
349
+ return (name) => {
350
+ const col = doc.columns.find((c) => c.name.trim().toLowerCase() === name.toLowerCase());
351
+ if (!col) return null;
352
+ // Guard against formula → formula recursion: a formula may not reference
353
+ // another formula column (avoids cycles without a full dependency graph).
354
+ if (col.type === 'formula') return null;
355
+ return cellToEval(row.cells[col.id] ?? null);
356
+ };
357
+ }
358
+
359
+ function cellToEval(v: CellValue): EvalValue {
360
+ if (Array.isArray(v)) return v.join(', ');
361
+ return v;
362
+ }
363
+
364
+ /**
365
+ * Parse a numeric string the ONE way the whole evaluator agrees on.
366
+ *
367
+ * This existing exactly once matters. `toNum` used to strip thousands
368
+ * separators while `compare` called bare `Number()`, so `'1,000'` was 1000 to
369
+ * arithmetic and NaN to a comparison — and a NaN comparison silently falls
370
+ * through to STRING ordering, where `'1,000' < '28.7'`. A piecewise branch
371
+ * guarded by `{Ps} > {Ptrans}` therefore selected the wrong equation and
372
+ * returned a plausible number from it. Any divergence here is a wrong-answer
373
+ * bug, not a formatting quirk.
374
+ */
375
+ function parseNumericString(s: string): number {
376
+ return Number(s.replace(/[, ]/g, ''));
377
+ }
378
+
379
+ function toNum(v: EvalValue): number {
380
+ if (typeof v === 'number') return v;
381
+ if (typeof v === 'boolean') return v ? 1 : 0;
382
+ if (typeof v === 'string' && v.trim() !== '') {
383
+ const n = parseNumericString(v);
384
+ return Number.isFinite(n) ? n : NaN;
385
+ }
386
+ return 0; // null / blank behaves as 0 in arithmetic
387
+ }
388
+
389
+ function toStr(v: EvalValue): string {
390
+ if (v === null || v === undefined) return '';
391
+ return String(v);
392
+ }
393
+
394
+ /** Exported so the formula-spec evaluator branches on exactly these rules. */
395
+ export function truthy(v: EvalValue): boolean {
396
+ if (typeof v === 'boolean') return v;
397
+ if (typeof v === 'number') return v !== 0;
398
+ if (typeof v === 'string') return v.trim() !== '' && v.trim().toLowerCase() !== 'false';
399
+ return false;
400
+ }
401
+
402
+ function compare(op: string, a: EvalValue, b: EvalValue): boolean {
403
+ const coerce = (v: EvalValue): number =>
404
+ typeof v === 'number' ? v : typeof v === 'string' ? parseNumericString(v) : Number(v);
405
+ const na = coerce(a);
406
+ const nb = coerce(b);
407
+ const numeric = Number.isFinite(na) && Number.isFinite(nb);
408
+ switch (op) {
409
+ case '==':
410
+ return numeric ? na === nb : toStr(a) === toStr(b);
411
+ case '!=':
412
+ return numeric ? na !== nb : toStr(a) !== toStr(b);
413
+ case '>':
414
+ return numeric ? na > nb : toStr(a) > toStr(b);
415
+ case '<':
416
+ return numeric ? na < nb : toStr(a) < toStr(b);
417
+ case '>=':
418
+ return numeric ? na >= nb : toStr(a) >= toStr(b);
419
+ case '<=':
420
+ return numeric ? na <= nb : toStr(a) <= toStr(b);
421
+ default:
422
+ return false;
423
+ }
424
+ }
425
+
426
+ function applyFn(name: FnName, args: EvalValue[]): EvalValue {
427
+ switch (name) {
428
+ case 'IF':
429
+ return truthy(args[0] ?? null) ? (args[1] ?? null) : (args[2] ?? null);
430
+ case 'ROUND': {
431
+ const n = toNum(args[0] ?? null);
432
+ const d = args.length > 1 ? toNum(args[1]!) : 0;
433
+ const f = 10 ** d;
434
+ return Math.round(n * f) / f;
435
+ }
436
+ case 'FLOOR':
437
+ return Math.floor(toNum(args[0] ?? null));
438
+ case 'CEIL':
439
+ return Math.ceil(toNum(args[0] ?? null));
440
+ case 'ABS':
441
+ return Math.abs(toNum(args[0] ?? null));
442
+ case 'MIN':
443
+ return Math.min(...args.map(toNum));
444
+ case 'MAX':
445
+ return Math.max(...args.map(toNum));
446
+ case 'SUM':
447
+ return args.map(toNum).reduce((a, b) => a + b, 0);
448
+ case 'CONCAT':
449
+ return args.map(toStr).join('');
450
+ // Scientific set. Out-of-domain inputs (SQRT of a negative, LN of zero)
451
+ // yield NaN or -Infinity, which evalFormula collapses to null — a formula
452
+ // outside its valid range renders blank rather than showing a bogus number.
453
+ case 'SQRT':
454
+ return Math.sqrt(toNum(args[0] ?? null));
455
+ case 'POW':
456
+ return toNum(args[0] ?? null) ** toNum(args[1] ?? null);
457
+ case 'LN':
458
+ return Math.log(toNum(args[0] ?? null));
459
+ case 'LOG10':
460
+ return Math.log10(toNum(args[0] ?? null));
461
+ case 'EXP':
462
+ return Math.exp(toNum(args[0] ?? null));
463
+ default:
464
+ return null;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Evaluate an expression against an arbitrary reference resolver, THROWING on
470
+ * a malformed expression. Callers that want a value or a diagnosis — the
471
+ * formula-spec evaluator, where a silently blank release rate would be worse
472
+ * than a loud failure — use this. Callers that want a cell to render want
473
+ * `evalFormula` below.
474
+ */
475
+ export function evalExpression(src: string, resolve: RefResolver): EvalValue {
476
+ const text = (src ?? '').trim();
477
+ if (!text) throw new Error('empty expression');
478
+ if (text.length > MAX_FORMULA_LEN) throw new Error('expression too long');
479
+ return new Parser(tokenize(text), resolve).parse();
480
+ }
481
+
482
+ /**
483
+ * Evaluate a formula expression in the context of one row. Returns a number,
484
+ * string, or null. Any parse/eval error yields null (a broken formula renders
485
+ * blank, never throws into the caller). NaN results collapse to null too.
486
+ */
487
+ export function evalFormula(formula: string, doc: TableDoc, row: Row): CellValue {
488
+ try {
489
+ const result = evalExpression(formula, columnResolver(doc, row));
490
+ if (typeof result === 'number') return Number.isFinite(result) ? result : null;
491
+ if (typeof result === 'boolean') return result;
492
+ return result ?? null;
493
+ } catch {
494
+ return null;
495
+ }
496
+ }