···2222 toNum, toBool, coerceForComparison, flat,
2323 parseRef as _parseRef, colToLetter as _colToLetter, letterToCol as _letterToCol, cellId as _cellId,
2424} from './formula-helpers.js';
2525-import { callMathFunction } from './formula-math.js';
2626-import { callTextFunction } from './formula-text.js';
2727-import { callDateFunction } from './formula-date.js';
2828-import { callLookupFunction } from './formula-lookup.js';
2929-import { callLogicalFunction } from './formula-logical.js';
3030-import { callFinancialFunction } from './formula-financial.js';
3131-import { callArrayFunction } from './formula-array.js';
2525+import { callMathFunction, MATH_FUNCTION_NAMES } from './formula-math.js';
2626+import { callTextFunction, TEXT_FUNCTION_NAMES } from './formula-text.js';
2727+import { callDateFunction, DATE_FUNCTION_NAMES } from './formula-date.js';
2828+import { callLookupFunction, LOOKUP_FUNCTION_NAMES } from './formula-lookup.js';
2929+import { callLogicalFunction, LOGICAL_FUNCTION_NAMES } from './formula-logical.js';
3030+import { callFinancialFunction, FINANCIAL_FUNCTION_NAMES } from './formula-financial.js';
3131+import { callArrayFunction, ARRAY_FUNCTION_NAMES } from './formula-array.js';
3232import { tokenize, TokenType } from './formula-tokenizer.js';
3333import { Parser } from './formula-parser.js';
3434···3838export const letterToCol = _letterToCol;
3939export const cellId = _cellId;
40404141-// --- Function dispatch ---
4141+// --- Function dispatch registry ---
4242+// Maps each function name to its dispatcher. Built at module load to detect
4343+// collisions eagerly — a duplicate name throws immediately instead of
4444+// silently shadowing at call time (see issue #555).
42454343-function callFunction(name: string, args: unknown[]): unknown {
4444- let result: unknown;
4646+type Dispatcher = (name: string, args: unknown[]) => unknown | undefined;
45474646- result = callMathFunction(name, args);
4747- if (result !== undefined) return result;
4848+const MODULES: Array<{ names: readonly string[]; call: Dispatcher; label: string }> = [
4949+ { names: MATH_FUNCTION_NAMES, call: callMathFunction, label: 'math' },
5050+ { names: LOGICAL_FUNCTION_NAMES, call: callLogicalFunction, label: 'logical' },
5151+ { names: TEXT_FUNCTION_NAMES, call: callTextFunction, label: 'text' },
5252+ { names: DATE_FUNCTION_NAMES, call: callDateFunction, label: 'date' },
5353+ { names: LOOKUP_FUNCTION_NAMES, call: callLookupFunction, label: 'lookup' },
5454+ { names: FINANCIAL_FUNCTION_NAMES, call: callFinancialFunction, label: 'financial' },
5555+ { names: ARRAY_FUNCTION_NAMES, call: callArrayFunction, label: 'array' },
5656+];
48574949- result = callLogicalFunction(name, args);
5050- if (result !== undefined) return result;
5858+const functionRegistry = new Map<string, Dispatcher>();
51595252- result = callTextFunction(name, args);
5353- if (result !== undefined) return result;
5454-5555- result = callDateFunction(name, args);
5656- if (result !== undefined) return result;
6060+for (const mod of MODULES) {
6161+ for (const name of mod.names) {
6262+ const existing = functionRegistry.get(name);
6363+ if (existing) {
6464+ const prevLabel = MODULES.find(m => m.call === existing)?.label ?? 'unknown';
6565+ throw new Error(
6666+ `Formula function collision: "${name}" is registered in both "${prevLabel}" and "${mod.label}" modules. ` +
6767+ 'Each function name must be unique across all formula modules.'
6868+ );
6969+ }
7070+ functionRegistry.set(name, mod.call);
7171+ }
7272+}
57735858- result = callLookupFunction(name, args);
5959- if (result !== undefined) return result;
7474+/** Exported for testing — the complete set of registered function names. */
7575+export const REGISTERED_FUNCTION_NAMES: ReadonlySet<string> = new Set(functionRegistry.keys());
60766161- result = callFinancialFunction(name, args);
6262- if (result !== undefined) return result;
7777+// --- Function dispatch ---
63786464- result = callArrayFunction(name, args);
6565- if (result !== undefined) return result;
7979+function callFunction(name: string, args: unknown[]): unknown {
8080+ const dispatcher = functionRegistry.get(name);
8181+ if (dispatcher) {
8282+ const result = dispatcher(name, args);
8383+ if (result !== undefined) return result;
8484+ }
66856786 // Functions that remain here due to local imports (sparkline, query)
6887 switch (name) {
+91-6
src/sheets/recalc.ts
···88 * - Tracks volatile functions (NOW, TODAY, RAND, RANDBETWEEN)
99 * - Supports incremental graph updates when a cell's formula changes
1010 * - Works with cross-sheet references (SheetName!CellId keys)
1111+ * - Detects cross-sheet circular dependencies via crossSheetFormulaDeps
1112 *
1213 * This module is DOM-free and operates on an abstract cell store interface.
1314 */
···4142 volatileCells: Set<string>;
4243 _cyclePaths: string[][];
43444444- // Spill tracking: source cell → list of spilled target cells
4545+ // Spill tracking: source cell -> list of spilled target cells
4546 _spillRanges: Map<string, string[]>;
4646- // Reverse map: spill target cell → source cell
4747+ // Reverse map: spill target cell -> source cell
4748 _spillTargets: Map<string, string>;
48494950 constructor(store: CellStore, options: RecalcOptions = {}) {
···7071 /**
7172 * Build the full dependency graph from scratch.
7273 * Call this once at initialization or when the entire sheet changes.
7474+ *
7575+ * When crossSheetFormulaDeps and currentSheetName are provided, also
7676+ * traverses cross-sheet references to detect circular dependencies
7777+ * that span multiple sheets.
7378 */
7479 buildFullGraph(): void {
7580 this.precedents.clear();
···8085 if (!cell.f) continue;
8186 this._addCellEdges(id, cell.f);
8287 }
8888+8989+ // Expand graph with cross-sheet formula dependencies for cycle detection
9090+ this._expandCrossSheetEdges();
8391 }
84928593 /**
···255263 }
256264257265 /**
266266+ * Check if a cell key is a cross-sheet reference (contains '!').
267267+ */
268268+ _isCrossSheetKey(key: string): boolean {
269269+ return key.includes('!');
270270+ }
271271+272272+ /**
273273+ * Expand the dependency graph by traversing cross-sheet references.
274274+ *
275275+ * When crossSheetFormulaDeps is available, for each cross-sheet ref in the
276276+ * graph (e.g. "Sheet2!B1"), we query its formula dependencies and add those
277277+ * edges to the graph. This continues transitively until all reachable
278278+ * cross-sheet cells have been explored.
279279+ *
280280+ * This allows Kahn's algorithm to detect cycles that span multiple sheets.
281281+ */
282282+ _expandCrossSheetEdges(): void {
283283+ const resolver = this.options.crossSheetFormulaDeps;
284284+ const currentSheet = this.options.currentSheetName;
285285+ if (!resolver || !currentSheet) return;
286286+287287+ // Collect all cross-sheet refs currently in the graph
288288+ const visited = new Set<string>();
289289+ const queue: string[] = [];
290290+291291+ for (const [, precs] of this.precedents) {
292292+ for (const ref of precs) {
293293+ if (this._isCrossSheetKey(ref) && !visited.has(ref)) {
294294+ queue.push(ref);
295295+ }
296296+ }
297297+ }
298298+299299+ // BFS: resolve each cross-sheet ref's own dependencies
300300+ while (queue.length > 0) {
301301+ const crossRef = queue.shift()!;
302302+ if (visited.has(crossRef)) continue;
303303+ visited.add(crossRef);
304304+305305+ const deps = resolver(crossRef);
306306+ if (!deps || deps.size === 0) continue;
307307+308308+ // Map deps: if a dep points back to our sheet, map to local key
309309+ const mappedDeps = new Set<string>();
310310+ for (const dep of deps) {
311311+ if (dep.startsWith(currentSheet + '!')) {
312312+ const localId = dep.slice(currentSheet.length + 1);
313313+ mappedDeps.add(localId);
314314+ } else {
315315+ mappedDeps.add(dep);
316316+ }
317317+ }
318318+319319+ // Add edges: crossRef depends on mappedDeps
320320+ this.precedents.set(crossRef, mappedDeps);
321321+ for (const dep of mappedDeps) {
322322+ if (!this.dependents.has(dep)) {
323323+ this.dependents.set(dep, new Set());
324324+ }
325325+ this.dependents.get(dep)!.add(crossRef);
326326+327327+ // If dep is a new cross-sheet ref, queue it for further expansion
328328+ if (this._isCrossSheetKey(dep) && !visited.has(dep)) {
329329+ queue.push(dep);
330330+ }
331331+ }
332332+ }
333333+ }
334334+335335+ /**
258336 * Resolve named range identifiers in a formula to actual cell references.
259337 * Returns the union of extractRefs results and named range cell refs.
260338 * @param {string} formula
···278356 const rangeStr = entry.range;
279357 const parts = rangeStr.split(':');
280358 if (parts.length === 2) {
281281- // Range — expand to individual cells
359359+ // Range - expand to individual cells
282360 const start = parseRef(parts[0]);
283361 const end = parseRef(parts[1]);
284362 if (start && end) {
···341419 this._cyclePaths = [];
342420 const changed = new Set<string>();
343421344344- // Filter to only formula cells that need recalculation
422422+ // Filter to only formula cells that need recalculation.
423423+ // Also include cross-sheet cells that have precedents in the graph
424424+ // (added by _expandCrossSheetEdges) -- these participate in cycle
425425+ // detection but are not evaluated locally.
345426 const formulaCells = new Set<string>();
427427+ const crossSheetFormulaCells = new Set<string>();
346428 for (const cellId of dirty) {
347429 const cell = this.store.get(cellId);
348430 if (cell && cell.f) {
349431 formulaCells.add(cellId);
432432+ } else if (this._isCrossSheetKey(cellId) && this.precedents.has(cellId)) {
433433+ formulaCells.add(cellId);
434434+ crossSheetFormulaCells.add(cellId);
350435 }
351436 }
352437···547632548633 const targetCell = this.store.get(targetId);
549634 if (targetCell && (targetCell.f || (targetCell.v !== '' && targetCell.v !== undefined))) {
550550- // Collision — set #SPILL! and don't spill
635635+ // Collision - set #SPILL! and don't spill
551636 this._clearSpill(sourceId, changed);
552637 const cell = this.store.get(sourceId);
553638 if (cell) {
···637722 */
638723 _dfsFindCycle(cellId: string, cycleCells: Set<string>, path: string[], inStack: Set<string>, visited: Set<string>): string[] | null {
639724 if (inStack.has(cellId)) {
640640- // Found a cycle — extract path from the first occurrence to here
725725+ // Found a cycle - extract path from the first occurrence to here
641726 const cycleStart = path.indexOf(cellId);
642727 const cyclePath = path.slice(cycleStart);
643728 cyclePath.push(cellId);
+11-8
src/sheets/row-col-ops.ts
···1818 * - Handles single cell refs (A1, $A1, A$1, $A$1)
1919 * - Handles range refs (A1:B10)
2020 * - Leaves cross-sheet refs (Sheet1!A1) untouched
2121- * - Absolute refs ($) are NOT shifted
2121+ * - Absolute refs ($A$1) ARE adjusted for structural changes (insert/delete).
2222+ * The $ sign only prevents adjustment during copy/paste, not structural ops.
2223 * - References to deleted rows/cols become #REF!
2324 */
2425export function adjustFormulaRefs(
···40414142/**
4243 * Adjust a single cell reference like A1, $A$1, $A1, A$1.
4444+ *
4545+ * Absolute refs ($) ARE adjusted for structural changes (insert/delete row/col).
4646+ * The $ sign only prevents adjustment during copy/paste operations.
4347 */
4448function adjustSingleRef(
4549 ref: string,
···5256 const col = letterToColNum(colLetter);
53575458 if (change.type === 'row') {
5555- if (rowAbs) return ref; // $1 => absolute, don't shift
5959+ // Absolute refs ($) ARE adjusted for structural insert/delete.
5660 if (change.delta > 0) {
5761 // Inserting rows: shift refs at or below index down
5862 if (row >= change.index) {
5959- return buildRef(colAbs, colLetter, false, row + change.delta);
6363+ return buildRef(colAbs, colLetter, rowAbs, row + change.delta);
6064 }
6165 } else {
6266 // Deleting rows (delta is negative)
···6771 return '#REF!';
6872 }
6973 if (row > deleteEnd) {
7070- return buildRef(colAbs, colLetter, false, row + change.delta);
7474+ return buildRef(colAbs, colLetter, rowAbs, row + change.delta);
7175 }
7276 }
7377 } else {
7474- // Column change
7575- if (colAbs) return ref; // $A => absolute, don't shift
7878+ // Column change — absolute refs ARE adjusted for structural changes
7679 if (change.delta > 0) {
7780 // Inserting columns: shift refs at or right of index
7881 if (col >= change.index) {
7979- return buildRef(false, colToLetter(col + change.delta), rowAbs, row);
8282+ return buildRef(colAbs, colToLetter(col + change.delta), rowAbs, row);
8083 }
8184 } else {
8285 // Deleting columns
···8790 return '#REF!';
8891 }
8992 if (col > deleteEnd) {
9090- return buildRef(false, colToLetter(col + change.delta), rowAbs, row);
9393+ return buildRef(colAbs, colToLetter(col + change.delta), rowAbs, row);
9194 }
9295 }
9396 }
+12
src/sheets/types.ts
···124124 onEvaluate?: (cellId: string) => void;
125125 namedRanges?: NamedRangesMap;
126126 crossSheetResolver?: CrossSheetResolver;
127127+ /**
128128+ * Resolve cross-sheet formula dependencies for cycle detection.
129129+ * Given a fully-qualified cell key (e.g. "Sheet2!B1"), returns the set of
130130+ * fully-qualified cell keys that cell's formula depends on.
131131+ * Returns null/undefined if the cell has no formula or the sheet doesn't exist.
132132+ */
133133+ crossSheetFormulaDeps?: (qualifiedCellId: string) => Set<string> | null | undefined;
134134+ /**
135135+ * The name of the current sheet. Used to map local cell IDs to fully-qualified
136136+ * keys when detecting cross-sheet circular dependencies.
137137+ */
138138+ currentSheetName?: string;
127139}
128140129141// --- Chart Types ---
+412
tests/cross-sheet-circular.test.ts
···11+import { describe, it, expect } from 'vitest';
22+import { RecalcEngine } from '../src/sheets/recalc.js';
33+44+/**
55+ * Cross-sheet circular dependency detection tests.
66+ *
77+ * Issue #522: The recalculation engine must detect circular dependencies
88+ * that span multiple sheets. For example, Sheet1!A1 references Sheet2!B1,
99+ * which references Sheet1!A1 — this is a cycle that must produce #CIRCULAR!.
1010+ *
1111+ * The dependency graph uses fully-qualified keys like "Sheet2!B1" for
1212+ * cross-sheet refs. The crossSheetFormulaDeps option allows the engine
1313+ * to resolve formula dependencies on other sheets for cycle detection.
1414+ */
1515+1616+// --- Helpers ---
1717+1818+/**
1919+ * Create a simple cell store from a plain object.
2020+ * Each entry: cellId -> { v, f } where f is formula string (without '=').
2121+ */
2222+function makeCellStore(data: Record<string, { v: unknown; f: string }>) {
2323+ const store = new Map<string, { v: unknown; f: string }>();
2424+ for (const [id, cell] of Object.entries(data)) {
2525+ store.set(id, { ...cell });
2626+ }
2727+ return {
2828+ get(id: string) { return store.get(id) || null; },
2929+ set(id: string, cell: { v: unknown; f: string }) { store.set(id, { ...cell }); },
3030+ has(id: string) { return store.has(id); },
3131+ entries() { return store.entries(); },
3232+ getAllFormulaCells() {
3333+ const result: Array<[string, { v: unknown; f: string }]> = [];
3434+ for (const [id, cell] of store.entries()) {
3535+ if (cell.f) result.push([id, cell]);
3636+ }
3737+ return result;
3838+ },
3939+ };
4040+}
4141+4242+/**
4343+ * Build a crossSheetFormulaDeps resolver from a multi-sheet data structure.
4444+ * sheetsFormulas: { sheetName: { cellId: Set<qualifiedRef> } }
4545+ *
4646+ * Given "Sheet2!B1", looks up sheetsFormulas["Sheet2"]["B1"] to get deps.
4747+ */
4848+function makeCrossSheetFormulaDeps(
4949+ sheetsFormulas: Record<string, Record<string, Set<string>>>,
5050+) {
5151+ return (qualifiedCellId: string): Set<string> | null => {
5252+ const bangIdx = qualifiedCellId.indexOf('!');
5353+ if (bangIdx === -1) return null;
5454+ const sheetName = qualifiedCellId.slice(0, bangIdx);
5555+ const cellId = qualifiedCellId.slice(bangIdx + 1);
5656+ const sheet = sheetsFormulas[sheetName];
5757+ if (!sheet) return null;
5858+ return sheet[cellId] || null;
5959+ };
6060+}
6161+6262+// =====================================================================
6363+// 1. SIMPLE CROSS-SHEET CYCLE: Sheet1!A1 -> Sheet2!B1 -> Sheet1!A1
6464+// =====================================================================
6565+6666+describe('Cross-sheet circular dependency detection', () => {
6767+ it('detects simple two-sheet cycle: Sheet1!A1 -> Sheet2!B1 -> Sheet1!A1', () => {
6868+ // Sheet1: A1 has formula referencing Sheet2!B1
6969+ const store = makeCellStore({
7070+ A1: { v: '', f: 'Sheet2!B1+1' },
7171+ });
7272+7373+ // Sheet2: B1 has formula referencing Sheet1!A1
7474+ const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({
7575+ Sheet2: {
7676+ B1: new Set(['Sheet1!A1']),
7777+ },
7878+ });
7979+8080+ const engine = new RecalcEngine(store, {
8181+ currentSheetName: 'Sheet1',
8282+ crossSheetFormulaDeps,
8383+ });
8484+ engine.buildFullGraph();
8585+8686+ const changed = engine.recalculate('A1');
8787+8888+ // A1 should be marked as circular
8989+ expect(store.get('A1')!.v).toBe('#CIRCULAR!');
9090+ expect(changed.has('A1')).toBe(true);
9191+ });
9292+9393+ it('provides cycle path for cross-sheet cycle', () => {
9494+ const store = makeCellStore({
9595+ A1: { v: '', f: 'Sheet2!B1+1' },
9696+ });
9797+9898+ const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({
9999+ Sheet2: {
100100+ B1: new Set(['Sheet1!A1']),
101101+ },
102102+ });
103103+104104+ const engine = new RecalcEngine(store, {
105105+ currentSheetName: 'Sheet1',
106106+ crossSheetFormulaDeps,
107107+ });
108108+ engine.buildFullGraph();
109109+110110+ engine.recalculate('A1');
111111+112112+ const cyclePaths = engine.getCyclePaths();
113113+ expect(cyclePaths.length).toBeGreaterThan(0);
114114+ const path = cyclePaths[0];
115115+ // Cycle should form a loop: first === last
116116+ expect(path[0]).toBe(path[path.length - 1]);
117117+ // Path should include both the local cell and the cross-sheet cell
118118+ expect(path.length).toBeGreaterThanOrEqual(3);
119119+ });
120120+121121+ // =====================================================================
122122+ // 2. LONGER CROSS-SHEET CYCLE THROUGH 3+ SHEETS
123123+ // =====================================================================
124124+125125+ it('detects cycle through 3 sheets: Sheet1!A1 -> Sheet2!A1 -> Sheet3!A1 -> Sheet1!A1', () => {
126126+ // Sheet1: A1 has formula referencing Sheet2!A1
127127+ const store = makeCellStore({
128128+ A1: { v: '', f: 'Sheet2!A1+1' },
129129+ });
130130+131131+ // Sheet2!A1 depends on Sheet3!A1, Sheet3!A1 depends on Sheet1!A1
132132+ const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({
133133+ Sheet2: {
134134+ A1: new Set(['Sheet3!A1']),
135135+ },
136136+ Sheet3: {
137137+ A1: new Set(['Sheet1!A1']),
138138+ },
139139+ });
140140+141141+ const engine = new RecalcEngine(store, {
142142+ currentSheetName: 'Sheet1',
143143+ crossSheetFormulaDeps,
144144+ });
145145+ engine.buildFullGraph();
146146+147147+ const changed = engine.recalculate('A1');
148148+149149+ expect(store.get('A1')!.v).toBe('#CIRCULAR!');
150150+ expect(changed.has('A1')).toBe(true);
151151+ });
152152+153153+ it('detects cycle through 4 sheets', () => {
154154+ // Sheet1!A1 -> Sheet2!B2 -> Sheet3!C3 -> Sheet4!D4 -> Sheet1!A1
155155+ const store = makeCellStore({
156156+ A1: { v: '', f: 'Sheet2!B2*2' },
157157+ });
158158+159159+ const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({
160160+ Sheet2: {
161161+ B2: new Set(['Sheet3!C3']),
162162+ },
163163+ Sheet3: {
164164+ C3: new Set(['Sheet4!D4']),
165165+ },
166166+ Sheet4: {
167167+ D4: new Set(['Sheet1!A1']),
168168+ },
169169+ });
170170+171171+ const engine = new RecalcEngine(store, {
172172+ currentSheetName: 'Sheet1',
173173+ crossSheetFormulaDeps,
174174+ });
175175+ engine.buildFullGraph();
176176+177177+ const changed = engine.recalculate('A1');
178178+179179+ expect(store.get('A1')!.v).toBe('#CIRCULAR!');
180180+ });
181181+182182+ // =====================================================================
183183+ // 3. VALID CROSS-SHEET REFERENCE (NOT CIRCULAR)
184184+ // =====================================================================
185185+186186+ it('does NOT flag valid cross-sheet reference as circular', () => {
187187+ // Sheet1!A1 -> Sheet2!B1 -> Sheet2!C1 (no cycle back to Sheet1)
188188+ const store = makeCellStore({
189189+ A1: { v: '', f: 'Sheet2!B1+1' },
190190+ });
191191+192192+ const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({
193193+ Sheet2: {
194194+ B1: new Set(['Sheet2!C1']), // depends on another Sheet2 cell, no cycle
195195+ },
196196+ });
197197+198198+ const engine = new RecalcEngine(store, {
199199+ currentSheetName: 'Sheet1',
200200+ crossSheetFormulaDeps,
201201+ crossSheetResolver: {
202202+ sheetExists: () => true,
203203+ getSheetCellValue: () => 42,
204204+ },
205205+ });
206206+ engine.buildFullGraph();
207207+208208+ const changed = engine.recalculate('A1');
209209+210210+ // A1 should NOT be circular
211211+ expect(store.get('A1')!.v).not.toBe('#CIRCULAR!');
212212+ expect(engine.getCyclePaths().length).toBe(0);
213213+ });
214214+215215+ it('evaluates valid cross-sheet chain correctly', () => {
216216+ // Sheet1!A1 references Sheet2!B1 which has no back-reference.
217217+ // A1's formula evaluates using crossSheetResolver.
218218+ const store = makeCellStore({
219219+ A1: { v: '', f: 'Sheet2!B1+10' },
220220+ });
221221+222222+ const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({
223223+ Sheet2: {
224224+ B1: new Set([]), // B1 is a plain value, no deps
225225+ },
226226+ });
227227+228228+ const engine = new RecalcEngine(store, {
229229+ currentSheetName: 'Sheet1',
230230+ crossSheetFormulaDeps,
231231+ crossSheetResolver: {
232232+ sheetExists: () => true,
233233+ getSheetCellValue: (_sheet: string, _ref: string) => 5,
234234+ },
235235+ });
236236+ engine.buildFullGraph();
237237+238238+ engine.recalculate('A1');
239239+240240+ // Should evaluate: 5 + 10 = 15
241241+ expect(store.get('A1')!.v).toBe(15);
242242+ });
243243+244244+ it('handles multiple local cells referencing different sheets without cycles', () => {
245245+ const store = makeCellStore({
246246+ A1: { v: '', f: 'Sheet2!A1+1' },
247247+ B1: { v: '', f: 'Sheet3!A1+2' },
248248+ C1: { v: '', f: 'A1+B1' },
249249+ });
250250+251251+ const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({
252252+ Sheet2: { A1: new Set([]) },
253253+ Sheet3: { A1: new Set([]) },
254254+ });
255255+256256+ const engine = new RecalcEngine(store, {
257257+ currentSheetName: 'Sheet1',
258258+ crossSheetFormulaDeps,
259259+ crossSheetResolver: {
260260+ sheetExists: () => true,
261261+ getSheetCellValue: () => 10,
262262+ },
263263+ });
264264+ engine.buildFullGraph();
265265+266266+ engine.recalculate('A1');
267267+ engine.recalculate('B1');
268268+269269+ expect(store.get('A1')!.v).not.toBe('#CIRCULAR!');
270270+ expect(store.get('B1')!.v).not.toBe('#CIRCULAR!');
271271+ expect(engine.getCyclePaths().length).toBe(0);
272272+ });
273273+274274+ // =====================================================================
275275+ // 4. SELF-REFERENCING CELL
276276+ // =====================================================================
277277+278278+ it('detects self-referencing cell (same as existing test, ensures no regression)', () => {
279279+ const store = makeCellStore({
280280+ A1: { v: '', f: 'A1+1' },
281281+ });
282282+ const engine = new RecalcEngine(store);
283283+ engine.buildFullGraph();
284284+285285+ engine.recalculate('A1');
286286+ expect(store.get('A1')!.v).toBe('#CIRCULAR!');
287287+ });
288288+289289+ it('detects self-referencing cell with cross-sheet options enabled', () => {
290290+ // Ensure the cross-sheet machinery doesn't break self-reference detection
291291+ const store = makeCellStore({
292292+ A1: { v: '', f: 'A1+1' },
293293+ });
294294+295295+ const engine = new RecalcEngine(store, {
296296+ currentSheetName: 'Sheet1',
297297+ crossSheetFormulaDeps: () => null,
298298+ });
299299+ engine.buildFullGraph();
300300+301301+ engine.recalculate('A1');
302302+ expect(store.get('A1')!.v).toBe('#CIRCULAR!');
303303+ });
304304+305305+ // =====================================================================
306306+ // 5. MIXED SCENARIOS
307307+ // =====================================================================
308308+309309+ it('marks only cyclic cells, not unrelated cells on the same sheet', () => {
310310+ // A1 is in a cross-sheet cycle; B1 is a normal formula
311311+ const store = makeCellStore({
312312+ A1: { v: '', f: 'Sheet2!A1+1' },
313313+ B1: { v: 10, f: '' },
314314+ C1: { v: '', f: 'B1*2' },
315315+ });
316316+317317+ const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({
318318+ Sheet2: {
319319+ A1: new Set(['Sheet1!A1']), // cycle!
320320+ },
321321+ });
322322+323323+ const engine = new RecalcEngine(store, {
324324+ currentSheetName: 'Sheet1',
325325+ crossSheetFormulaDeps,
326326+ });
327327+ engine.buildFullGraph();
328328+329329+ engine.recalculate('A1');
330330+ engine.recalculate('B1');
331331+332332+ // A1 is circular
333333+ expect(store.get('A1')!.v).toBe('#CIRCULAR!');
334334+ // C1 is not circular -- it depends only on B1
335335+ expect(store.get('C1')!.v).toBe(20);
336336+ });
337337+338338+ it('detects cycle introduced by cross-sheet dependency after incremental update', () => {
339339+ // Initially A1 is a plain value, no cycle
340340+ const store = makeCellStore({
341341+ A1: { v: 10, f: '' },
342342+ });
343343+344344+ const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({
345345+ Sheet2: {
346346+ B1: new Set(['Sheet1!A1']),
347347+ },
348348+ });
349349+350350+ const engine = new RecalcEngine(store, {
351351+ currentSheetName: 'Sheet1',
352352+ crossSheetFormulaDeps,
353353+ });
354354+ engine.buildFullGraph();
355355+356356+ // No cycle yet
357357+ engine.recalculate('A1');
358358+ expect(store.get('A1')!.v).toBe(10);
359359+360360+ // Now edit A1 to create a formula that references Sheet2!B1 -> cycle
361361+ store.set('A1', { v: '', f: 'Sheet2!B1+1' });
362362+ engine.updateCell('A1');
363363+ // Rebuild to pick up cross-sheet edges
364364+ engine.buildFullGraph();
365365+366366+ const changed = engine.recalculate('A1');
367367+ expect(store.get('A1')!.v).toBe('#CIRCULAR!');
368368+ });
369369+370370+ it('works without crossSheetFormulaDeps (backwards compatibility)', () => {
371371+ // When crossSheetFormulaDeps is not provided, cross-sheet cycles
372372+ // won't be detected, but existing same-sheet behavior must work
373373+ const store = makeCellStore({
374374+ A1: { v: '', f: 'B1+1' },
375375+ B1: { v: '', f: 'A1+1' },
376376+ });
377377+378378+ const engine = new RecalcEngine(store);
379379+ engine.buildFullGraph();
380380+381381+ engine.recalculate('A1');
382382+ expect(store.get('A1')!.v).toBe('#CIRCULAR!');
383383+ expect(store.get('B1')!.v).toBe('#CIRCULAR!');
384384+ });
385385+386386+ it('handles cross-sheet ref to a cell with no formula (leaf node)', () => {
387387+ // Sheet1!A1 -> Sheet2!B1 where Sheet2!B1 is a plain value (no formula deps)
388388+ const store = makeCellStore({
389389+ A1: { v: '', f: 'Sheet2!B1+1' },
390390+ });
391391+392392+ const crossSheetFormulaDeps = makeCrossSheetFormulaDeps({
393393+ Sheet2: {}, // B1 has no formula entry at all
394394+ });
395395+396396+ const engine = new RecalcEngine(store, {
397397+ currentSheetName: 'Sheet1',
398398+ crossSheetFormulaDeps,
399399+ crossSheetResolver: {
400400+ sheetExists: () => true,
401401+ getSheetCellValue: () => 7,
402402+ },
403403+ });
404404+ engine.buildFullGraph();
405405+406406+ engine.recalculate('A1');
407407+408408+ // Should evaluate normally: 7 + 1 = 8
409409+ expect(store.get('A1')!.v).toBe(8);
410410+ expect(engine.getCyclePaths().length).toBe(0);
411411+ });
412412+});
+98
tests/formula-dispatch.test.ts
···11+import { describe, it, expect } from 'vitest';
22+import { evaluate, REGISTERED_FUNCTION_NAMES } from '../src/sheets/formulas.js';
33+import { MATH_FUNCTION_NAMES } from '../src/sheets/formula-math.js';
44+import { TEXT_FUNCTION_NAMES } from '../src/sheets/formula-text.js';
55+import { DATE_FUNCTION_NAMES } from '../src/sheets/formula-date.js';
66+import { LOOKUP_FUNCTION_NAMES } from '../src/sheets/formula-lookup.js';
77+import { LOGICAL_FUNCTION_NAMES } from '../src/sheets/formula-logical.js';
88+import { FINANCIAL_FUNCTION_NAMES } from '../src/sheets/formula-financial.js';
99+import { ARRAY_FUNCTION_NAMES } from '../src/sheets/formula-array.js';
1010+1111+// Helper: evaluate with a simple cell map
1212+function evalWith(formula: string, cells: Record<string, unknown> = {}) {
1313+ return evaluate(formula, (ref) => cells[ref] ?? '');
1414+}
1515+1616+// All module name lists and their labels (mirrors the registry in formulas.ts)
1717+const ALL_MODULES: Array<{ names: readonly string[]; label: string }> = [
1818+ { names: MATH_FUNCTION_NAMES, label: 'math' },
1919+ { names: LOGICAL_FUNCTION_NAMES, label: 'logical' },
2020+ { names: TEXT_FUNCTION_NAMES, label: 'text' },
2121+ { names: DATE_FUNCTION_NAMES, label: 'date' },
2222+ { names: LOOKUP_FUNCTION_NAMES, label: 'lookup' },
2323+ { names: FINANCIAL_FUNCTION_NAMES, label: 'financial' },
2424+ { names: ARRAY_FUNCTION_NAMES, label: 'array' },
2525+];
2626+2727+describe('Formula dispatch registry — collision prevention (#555)', () => {
2828+ it('has no duplicate function names across modules', () => {
2929+ const seen = new Map<string, string>(); // name -> module label
3030+ const duplicates: string[] = [];
3131+3232+ for (const mod of ALL_MODULES) {
3333+ for (const name of mod.names) {
3434+ const existing = seen.get(name);
3535+ if (existing) {
3636+ duplicates.push(`"${name}" in both "${existing}" and "${mod.label}"`);
3737+ } else {
3838+ seen.set(name, mod.label);
3939+ }
4040+ }
4141+ }
4242+4343+ expect(duplicates, `Colliding function names found: ${duplicates.join(', ')}`).toEqual([]);
4444+ });
4545+4646+ it('has no duplicate names within a single module', () => {
4747+ for (const mod of ALL_MODULES) {
4848+ const unique = new Set(mod.names);
4949+ expect(
5050+ unique.size,
5151+ `Module "${mod.label}" has ${mod.names.length - unique.size} internal duplicates`
5252+ ).toBe(mod.names.length);
5353+ }
5454+ });
5555+5656+ it('REGISTERED_FUNCTION_NAMES contains all module functions', () => {
5757+ for (const mod of ALL_MODULES) {
5858+ for (const name of mod.names) {
5959+ expect(
6060+ REGISTERED_FUNCTION_NAMES.has(name),
6161+ `"${name}" from ${mod.label} missing from registry`
6262+ ).toBe(true);
6363+ }
6464+ }
6565+ });
6666+6767+ it('every registered name resolves (not #NAME?)', () => {
6868+ // Test a representative sample from each module to ensure dispatch works.
6969+ const sampleFunctions: Array<{ fn: string; formula: string }> = [
7070+ { fn: 'SUM', formula: 'SUM(1,2,3)' },
7171+ { fn: 'IF', formula: 'IF(TRUE,1,0)' },
7272+ { fn: 'LEN', formula: 'LEN("hello")' },
7373+ { fn: 'YEAR', formula: 'YEAR("2024-01-15")' },
7474+ { fn: 'INDEX', formula: 'INDEX(A1:A3,1)' },
7575+ { fn: 'PMT', formula: 'PMT(0.05,12,1000)' },
7676+ { fn: 'SEQUENCE', formula: 'SEQUENCE(3)' },
7777+ ];
7878+7979+ for (const { fn, formula } of sampleFunctions) {
8080+ const result = evalWith(formula, { A1: 10, A2: 20, A3: 30 });
8181+ expect(
8282+ typeof result === 'string' && result.startsWith('#NAME?'),
8383+ `${fn} dispatch failed — got #NAME? for ${formula}`
8484+ ).toBe(false);
8585+ }
8686+ });
8787+8888+ it('non-module functions (QUERY, SPARKLINE) still dispatch correctly', () => {
8989+ // SPARKLINE with valid data should not return #NAME?
9090+ const sparkResult = evalWith('SPARKLINE(A1:A3)', { A1: 1, A2: 2, A3: 3 });
9191+ expect(typeof sparkResult === 'string' && sparkResult.startsWith('#NAME?')).toBe(false);
9292+ });
9393+9494+ it('unknown function names still return #NAME?', () => {
9595+ const result = evalWith('NOTAFUNCTION(1,2)');
9696+ expect(result).toBe('#NAME? (NOTAFUNCTION)');
9797+ });
9898+});