From 4afa9f313827adf9106255885974b020d1cddbc8 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 8 Sep 2026 05:16:52 +0000 Subject: [PATCH 1/7] HF-131: stop rootless errors from claiming another cell's address An error read out of a neighbouring cell is not produced by the cell that read it. This closes the remaining rootless-origin cases the choke-point redesign's propagated-skip needed but did not itself provide identities for: - A parsing error is attributed to 'parser' (ParsingErrorVertex.getCellValue). - An error value typed directly into a cell is attributed to 'user input' (CellContentParser's CellContent.Error constructor). - An error literal written into a formula (e.g. =#VALUE!+1) is attributed to 'literal' (FormulaParser's ErrorLiteral alternative). - A #SPILL! error reports the cell that failed to spill, not no address at all: setNoSpace() attaches itself as root, since it already has 'this' in hand. getScalarValue's range-to-scalar coercion class was measured, not assumed: the requesting cell is genuinely the one that made the invalid request, so 'no address' there is honest, not a bug, and is left unchanged. known-limitations.md gains one bullet: the six buildErrorWithRawInputAst sites in FormulaParser.ts (malformed references) stay unattributed rather than mislabeled, pending a future task. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + docs/guide/known-limitations.md | 1 + src/CellContentParser.ts | 2 +- src/DependencyGraph/FormulaVertex.ts | 2 +- src/DependencyGraph/ParsingErrorVertex.ts | 2 +- src/parser/FormulaParser.ts | 2 +- 6 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a6f4e58d..5aea949488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed - A cell error read from another cell no longer reports the reading cell as its origin. Errors that come from a cell holding a static error value — a formula with a syntax error, or an error value entered directly — now report that cell's address instead of the address of whichever cell happened to read them first. [#131](https://github.com/handsontable/hyperformula/issues/131) +- A parsing error, an error value typed directly into a cell, and an error literal written into a formula are now attributed to `parser`, `user input`, or `literal` respectively, instead of being silently unattributed or (for a parsing error) reporting no address at all. `#SPILL!` errors now report the cell that failed to spill instead of no address. [#131](https://github.com/handsontable/hyperformula/issues/131) ## [3.4.0] - 2026-08-10 diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 91b161d94a..9def446c56 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -47,6 +47,7 @@ a circular reference. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. * The TEXT function does not accept embedded double-quote literals in the format string. In Excel, `""` inside a format string is an escape sequence for a literal `"` character — e.g. `=TEXT(1234.5, "#,##0.00 ""zł""")` returns `"1,234.50 zł"`. If your application requires this escape sequence, supply a custom [`stringifyCurrency`](currency-handling.md) callback. +* The six reference-checking branches in the formula parser that build a `#REF!`/`#NAME?` error for a malformed reference (for example a range that spans more than one sheet) report no `originFunction` — the identity is left unattributed rather than mislabeled as a function that only read the value. ### UNIQUE function diff --git a/src/CellContentParser.ts b/src/CellContentParser.ts index 4a14b443d2..5dcda54bc1 100644 --- a/src/CellContentParser.ts +++ b/src/CellContentParser.ts @@ -71,7 +71,7 @@ export namespace CellContent { // caller passes no message, because the rule only inspects this call site, // not who calls it. constructor(errorType: ErrorType, message?: string) { - this.value = new CellError(errorType, message) + this.value = new CellError(errorType, message).withOrigin('user input') } } diff --git a/src/DependencyGraph/FormulaVertex.ts b/src/DependencyGraph/FormulaVertex.ts index 84f54499ab..fbea5f644e 100644 --- a/src/DependencyGraph/FormulaVertex.ts +++ b/src/DependencyGraph/FormulaVertex.ts @@ -177,7 +177,7 @@ export class ArrayFormulaVertex extends FormulaVertex { } setNoSpace(): InterpreterValue { - this.array = new ErroredArray(new CellError(ErrorType.SPILL, ErrorMessage.NoSpaceForArrayResult), ArraySize.error()) + this.array = new ErroredArray(new CellError(ErrorType.SPILL, ErrorMessage.NoSpaceForArrayResult, this), ArraySize.error()) return this.getCellValue() } diff --git a/src/DependencyGraph/ParsingErrorVertex.ts b/src/DependencyGraph/ParsingErrorVertex.ts index 756b32f680..65333b4956 100644 --- a/src/DependencyGraph/ParsingErrorVertex.ts +++ b/src/DependencyGraph/ParsingErrorVertex.ts @@ -26,7 +26,7 @@ export class ParsingErrorVertex extends CellVertex { */ public getCellValue(): CellError { const firstNonemptyMessage = this.errors.map(error => error.message).find((msg) => msg) - return CellError.parsingError(firstNonemptyMessage) + return CellError.parsingError(firstNonemptyMessage).withOrigin('parser') } /** diff --git a/src/parser/FormulaParser.ts b/src/parser/FormulaParser.ts index 3bfb977a44..aa0e34f11c 100644 --- a/src/parser/FormulaParser.ts +++ b/src/parser/FormulaParser.ts @@ -552,7 +552,7 @@ export class FormulaParser extends EmbeddedActionsParser { const errString = token.image.toUpperCase() const errorType = this.lexerConfig.errorMapping[errString] if (errorType) { - return buildCellErrorAst(new CellError(errorType, ErrorMessage.ErrorLiteral), token.leadingWhitespace) + return buildCellErrorAst(new CellError(errorType, ErrorMessage.ErrorLiteral).withOrigin('literal'), token.leadingWhitespace) } else { return this.parsingError(ParsingErrorType.ParserError, 'Unknown error literal') } From 9e8b818d232aeb8d28432926973cc2a3d546f026 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 14 Sep 2026 10:04:30 +0000 Subject: [PATCH 2/7] HF-131: stop a function claiming an error it only read An error that describes a broken reference exists before any function sees it, so the enclosing call is only a reader. originNameForAstNode returns undefined for reference and error nodes, so such an error reached the enclosing FUNCTION_CALL unclaimed and non-propagated, and the postprocessing tail handed it to that call: =SUM(A2:A99999999999) reported SUM, =ABS(A2:A99999999999) reported ABS with an argument index that belonged to nothing, and =SUM(#REF!) after removing rows reported SUM. The identity is now attached where the error is built, which lets the existing first-wins rule block the reader by itself. A reference that cannot be resolved reports 'reference', whether the parser rejected it, the sheet is missing, the range spans several sheets, or the name is unknown; one destroyed by removing rows or columns reports 'removed reference'; the out-of-sheet error raised inside the OFFSET heuristic reports 'OFFSET', because OFFSET really did produce it. Errors that arise inside an array result are unchanged and still take the reading function's name; that needs either a hook over every ad-hoc array element or the function name threaded through three methods of the plugin base class, so it is left out and stated in the known limitations. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + docs/guide/known-limitations.md | 2 +- src/DependencyGraph/DependencyGraph.ts | 2 +- src/dependencyTransformers/Transformer.ts | 8 ++++---- src/interpreter/Interpreter.ts | 18 +++++++++--------- src/parser/FormulaParser.ts | 22 +++++++++++----------- 6 files changed, 27 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f6f2a8904..f1c05b2b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Fixed a cell holding a static error value — a formula the parser rejected, or an error value entered directly — being reported under the address of whichever cell read it first. Such a cell now reports its own address, and keeps reporting it as rows and columns move around it. After a very long run of structural changes the original position can no longer be reconstructed, and the address is omitted rather than guessed. [#1547](https://github.com/handsontable/hyperformula/issues/1547) - A parsing error, an error value typed directly into a cell, and an error literal written into a formula are now attributed to `parser`, `user input`, or `literal` respectively, instead of being silently unattributed or (for a parsing error) reporting no address at all. `#SPILL!` errors now report the cell that failed to spill instead of no address. [#1547](https://github.com/handsontable/hyperformula/issues/1547) +- Fixed a function or operator being reported as the producer of an error it only read. A reference that cannot be resolved now reports `reference`, and one destroyed by removing rows or columns reports `removed reference`, so `=SUM(A2:A99999999999)` no longer reports `SUM` and `=ABS(A2:A99999999999)` no longer reports an argument index that belongs to nothing. Errors arising inside an array result are not covered; see the known limitations. [#1547](https://github.com/handsontable/hyperformula/issues/1547) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 9def446c56..eb0efe9cb1 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -47,7 +47,7 @@ a circular reference. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. * The TEXT function does not accept embedded double-quote literals in the format string. In Excel, `""` inside a format string is an escape sequence for a literal `"` character — e.g. `=TEXT(1234.5, "#,##0.00 ""zł""")` returns `"1,234.50 zł"`. If your application requires this escape sequence, supply a custom [`stringifyCurrency`](currency-handling.md) callback. -* The six reference-checking branches in the formula parser that build a `#REF!`/`#NAME?` error for a malformed reference (for example a range that spans more than one sheet) report no `originFunction` — the identity is left unattributed rather than mislabeled as a function that only read the value. +* Errors that arise inside an array result — a broadcast that has no value for a position, or a per-element type coercion that fails — report no address, and the function whose call the array is passed to may be reported as their `originFunction`. For example, `=INDEX(A1:A2+B1:B3, 3, 1)` reports `INDEX` for an `#N/A` that the `+` operator produced. Errors produced outside an array carry the identity of whatever produced them: a function or operator that rejected a value, `reference` for a reference that cannot be resolved, `removed reference` for one destroyed by removing rows or columns, `parser`, `user input`, or `literal`. `#SPILL!` and `#CYCLE!` report the affected cell with no `originFunction`, because no function produced them. ### UNIQUE function diff --git a/src/DependencyGraph/DependencyGraph.ts b/src/DependencyGraph/DependencyGraph.ts index 81270bccc5..6e9b07da39 100644 --- a/src/DependencyGraph/DependencyGraph.ts +++ b/src/DependencyGraph/DependencyGraph.ts @@ -609,7 +609,7 @@ export class DependencyGraph { public getCellValue(address: SimpleCellAddress): InterpreterValue { if (this.isPlaceholder(address.sheet)) { - return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + return new CellError(ErrorType.REF, ErrorMessage.SheetRef).withOrigin('reference') } const value = this.addressMapping.getCellValue(address) diff --git a/src/dependencyTransformers/Transformer.ts b/src/dependencyTransformers/Transformer.ts index fc937d1c27..5d0258a83a 100644 --- a/src/dependencyTransformers/Transformer.ts +++ b/src/dependencyTransformers/Transformer.ts @@ -113,7 +113,7 @@ export abstract class Transformer implements FormulaTransformer { if (newCellAddress instanceof CellAddress) { return {...ast, reference: newCellAddress} } else if (newCellAddress === ErrorType.REF) { - return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.ReferenceRemoved)) + return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.ReferenceRemoved).withOrigin('removed reference')) } else { return ast } @@ -124,7 +124,7 @@ export abstract class Transformer implements FormulaTransformer { if (Array.isArray(newRange)) { return {...ast, start: newRange[0], end: newRange[1]} } else if (newRange === ErrorType.REF) { - return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.ReferenceRemoved)) + return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.ReferenceRemoved).withOrigin('removed reference')) } else { return ast } @@ -135,7 +135,7 @@ export abstract class Transformer implements FormulaTransformer { if (Array.isArray(newRange)) { return {...ast, start: newRange[0], end: newRange[1]} } else if (newRange === ErrorType.REF) { - return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.ReferenceRemoved)) + return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.ReferenceRemoved).withOrigin('removed reference')) } else { return ast } @@ -146,7 +146,7 @@ export abstract class Transformer implements FormulaTransformer { if (Array.isArray(newRange)) { return {...ast, start: newRange[0], end: newRange[1]} } else if (newRange === ErrorType.REF) { - return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.ReferenceRemoved)) + return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.ReferenceRemoved).withOrigin('removed reference')) } else { return ast } diff --git a/src/interpreter/Interpreter.ts b/src/interpreter/Interpreter.ts index 9f066daf6c..d1225d546c 100644 --- a/src/interpreter/Interpreter.ts +++ b/src/interpreter/Interpreter.ts @@ -96,11 +96,11 @@ export class Interpreter { const address = ast.reference.toSimpleCellAddress(state.formulaAddress) if (isColOrRowInvalid(address)) { - return new CellError(ErrorType.REF, ErrorMessage.BadRef) + return new CellError(ErrorType.REF, ErrorMessage.BadRef).withOrigin('reference') } if (!this.isSheetValid(ast.reference)) { - return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + return new CellError(ErrorType.REF, ErrorMessage.SheetRef).withOrigin('reference') } return this.dependencyGraph.getCellValue(address) @@ -197,16 +197,16 @@ export class Interpreter { if (namedExpression) { return this.dependencyGraph.getCellValue(namedExpression.address) } else { - return new CellError(ErrorType.NAME, ErrorMessage.NamedExpressionName(ast.expressionName)) + return new CellError(ErrorType.NAME, ErrorMessage.NamedExpressionName(ast.expressionName)).withOrigin('reference') } } case AstNodeType.CELL_RANGE: { if (!this.isSheetValid(ast.start) || !this.isSheetValid(ast.end)) { - return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + return new CellError(ErrorType.REF, ErrorMessage.SheetRef).withOrigin('reference') } if (!this.rangeSpansOneSheet(ast)) { - return new CellError(ErrorType.REF, ErrorMessage.RangeManySheets) + return new CellError(ErrorType.REF, ErrorMessage.RangeManySheets).withOrigin('reference') } const range = AbsoluteCellRange.fromCellRange(ast, state.formulaAddress) @@ -229,22 +229,22 @@ export class Interpreter { } case AstNodeType.COLUMN_RANGE: { if (!this.isSheetValid(ast.start) || !this.isSheetValid(ast.end)) { - return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + return new CellError(ErrorType.REF, ErrorMessage.SheetRef).withOrigin('reference') } if (!this.rangeSpansOneSheet(ast)) { - return new CellError(ErrorType.REF, ErrorMessage.RangeManySheets) + return new CellError(ErrorType.REF, ErrorMessage.RangeManySheets).withOrigin('reference') } const range = AbsoluteColumnRange.fromColumnRange(ast, state.formulaAddress) return SimpleRangeValue.onlyRange(range, this.dependencyGraph) } case AstNodeType.ROW_RANGE: { if (!this.isSheetValid(ast.start) || !this.isSheetValid(ast.end)) { - return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + return new CellError(ErrorType.REF, ErrorMessage.SheetRef).withOrigin('reference') } if (!this.rangeSpansOneSheet(ast)) { - return new CellError(ErrorType.REF, ErrorMessage.RangeManySheets) + return new CellError(ErrorType.REF, ErrorMessage.RangeManySheets).withOrigin('reference') } const range = AbsoluteRowRange.fromRowRangeAst(ast, state.formulaAddress) return SimpleRangeValue.onlyRange(range, this.dependencyGraph) diff --git a/src/parser/FormulaParser.ts b/src/parser/FormulaParser.ts index aa0e34f11c..b7efab1b7d 100644 --- a/src/parser/FormulaParser.ts +++ b/src/parser/FormulaParser.ts @@ -213,11 +213,11 @@ export class FormulaParser extends EmbeddedActionsParser { const secondAddress = this.ACTION(() => columnAddressFromString(endImage, this.formulaAddress, this.resolveSheetReference)) if (firstAddress === undefined || secondAddress === undefined) { - return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.UnresolvedReference)) + return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.UnresolvedReference).withOrigin('reference')) } if (firstAddress.exceedsSheetSizeLimits(this.lexerConfig.maxColumns) || secondAddress.exceedsSheetSizeLimits(this.lexerConfig.maxColumns)) { - return buildErrorWithRawInputAst(range.image, new CellError(ErrorType.NAME, ErrorMessage.ReferenceExceedsSheetSize), range.leadingWhitespace) + return buildErrorWithRawInputAst(range.image, new CellError(ErrorType.NAME, ErrorMessage.ReferenceExceedsSheetSize).withOrigin('reference'), range.leadingWhitespace) } if (firstAddress.sheet === undefined && secondAddress.sheet !== undefined) { @@ -239,11 +239,11 @@ export class FormulaParser extends EmbeddedActionsParser { const secondAddress = this.ACTION(() => rowAddressFromString(endImage, this.formulaAddress, this.resolveSheetReference)) if (firstAddress === undefined || secondAddress === undefined) { - return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.UnresolvedReference)) + return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.UnresolvedReference).withOrigin('reference')) } if (firstAddress.exceedsSheetSizeLimits(this.lexerConfig.maxRows) || secondAddress.exceedsSheetSizeLimits(this.lexerConfig.maxRows)) { - return buildErrorWithRawInputAst(range.image, new CellError(ErrorType.NAME, ErrorMessage.ReferenceExceedsSheetSize), range.leadingWhitespace) + return buildErrorWithRawInputAst(range.image, new CellError(ErrorType.NAME, ErrorMessage.ReferenceExceedsSheetSize).withOrigin('reference'), range.leadingWhitespace) } if (firstAddress.sheet === undefined && secondAddress.sheet !== undefined) { @@ -265,9 +265,9 @@ export class FormulaParser extends EmbeddedActionsParser { }) if (address === undefined) { - return buildErrorWithRawInputAst(cell.image, new CellError(ErrorType.REF, ErrorMessage.UnresolvedReference), cell.leadingWhitespace) + return buildErrorWithRawInputAst(cell.image, new CellError(ErrorType.REF, ErrorMessage.UnresolvedReference).withOrigin('reference'), cell.leadingWhitespace) } else if (address.exceedsSheetSizeLimits(this.lexerConfig.maxColumns, this.lexerConfig.maxRows)) { - return buildErrorWithRawInputAst(cell.image, new CellError(ErrorType.NAME, ErrorMessage.ReferenceExceedsSheetSize), cell.leadingWhitespace) + return buildErrorWithRawInputAst(cell.image, new CellError(ErrorType.NAME, ErrorMessage.ReferenceExceedsSheetSize).withOrigin('reference'), cell.leadingWhitespace) } else { return buildCellReferenceAst(address, cell.leadingWhitespace) } @@ -288,12 +288,12 @@ export class FormulaParser extends EmbeddedActionsParser { if (startAddress === undefined || endAddress === undefined) { return this.ACTION(() => { - return buildErrorWithRawInputAst(`${start.image}:${end.image}`, new CellError(ErrorType.REF, ErrorMessage.UnresolvedReference), start.leadingWhitespace) + return buildErrorWithRawInputAst(`${start.image}:${end.image}`, new CellError(ErrorType.REF, ErrorMessage.UnresolvedReference).withOrigin('reference'), start.leadingWhitespace) }) } else if (startAddress.exceedsSheetSizeLimits(this.lexerConfig.maxColumns, this.lexerConfig.maxRows) || endAddress.exceedsSheetSizeLimits(this.lexerConfig.maxColumns, this.lexerConfig.maxRows)) { return this.ACTION(() => { - return buildErrorWithRawInputAst(`${start.image}:${end.image}`, new CellError(ErrorType.NAME, ErrorMessage.ReferenceExceedsSheetSize), start.leadingWhitespace) + return buildErrorWithRawInputAst(`${start.image}:${end.image}`, new CellError(ErrorType.NAME, ErrorMessage.ReferenceExceedsSheetSize).withOrigin('reference'), start.leadingWhitespace) }) } @@ -319,7 +319,7 @@ export class FormulaParser extends EmbeddedActionsParser { return cellAddressFromString(start.image, this.formulaAddress, this.resolveSheetReference) }) if (startAddress === undefined) { - return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.UnresolvedReference)) + return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.UnresolvedReference).withOrigin('reference')) } if (offsetProcedure.type === AstNodeType.CELL_REFERENCE) { return this.buildCellRange(startAddress, offsetProcedure.reference, start.leadingWhitespace?.image) @@ -352,7 +352,7 @@ export class FormulaParser extends EmbeddedActionsParser { if (endAddress === undefined) { return this.ACTION(() => { - return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.UnresolvedReference)) + return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.UnresolvedReference).withOrigin('reference')) }) } @@ -835,7 +835,7 @@ export class FormulaParser extends EmbeddedActionsParser { } if (absoluteCol < 0 || absoluteRow < 0) { - return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.OutOfSheet)) + return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.OutOfSheet).withOrigin('OFFSET')) } if (width === 1 && height === 1) { return buildCellReferenceAst(topLeftCorner) From 8cf9593064d3be095425e6e389b4418f86fbd9e1 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 15 Sep 2026 07:24:59 +0000 Subject: [PATCH 3/7] HF-131: an error made inside an array is attributed to the operation that made it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An error that is one element of a returned array never reached the scalar branch of evaluateAst's tail, so it arrived at the enclosing call unclaimed and that call took it: =INDEX(A1:A2+B1:B3, 3, 1) reported INDEX for an #N/A the + operator produced, and a per-element coercion failure carried an argumentIndex with no originFunction at all, which the public JSDoc promises never happens. The tail now stamps the elements of an array the node itself built, which is the same first-wins rule applied one level down. A range-backed value is skipped: it is a view over cells whose errors were already marked when they were read, and reading its data would materialise the whole range on the hot path. That guard is not cosmetic — without it 'Sheet A' measured +5.7% against a 0.6% base spread; with it, four alternating rounds against the branch point give -1.09% overall and nothing outside its own spread. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- docs/guide/known-limitations.md | 2 +- src/interpreter/Interpreter.ts | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79bbfd8266..7574498210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Fixed a cell holding a static error value — a formula the parser rejected, or an error value entered directly — being reported under the address of whichever cell read it first. Such a cell now reports its own address, and keeps reporting it as rows and columns move around it. After a very long run of structural changes the original position can no longer be reconstructed, and the address is omitted rather than guessed. [#1547](https://github.com/handsontable/hyperformula/issues/1547) - A parsing error, an error value typed directly into a cell, and an error literal written into a formula are now attributed to `parser`, `user input`, or `literal` respectively, instead of being silently unattributed or (for a parsing error) reporting no address at all. `#SPILL!` errors now report the cell that failed to spill instead of no address. [#1547](https://github.com/handsontable/hyperformula/issues/1547) -- Fixed a function or operator being reported as the producer of an error it only read. A reference that cannot be resolved now reports `reference`, and one destroyed by removing rows or columns reports `removed reference`, so `=SUM(A2:A99999999999)` no longer reports `SUM` and `=ABS(A2:A99999999999)` no longer reports an argument index that belongs to nothing. Errors arising inside an array result are not covered; see the known limitations. [#1547](https://github.com/handsontable/hyperformula/issues/1547) +- Fixed a function or operator being reported as the producer of an error it only read. A reference that cannot be resolved now reports `reference`, and one destroyed by removing rows or columns reports `removed reference`, so `=SUM(A2:A99999999999)` no longer reports `SUM` and `=ABS(A2:A99999999999)` no longer reports an argument index that belongs to nothing. Errors that arise inside an array are attributed to the operation that built the array, so a broadcast gap in `=INDEX(A1:A2+B1:B3, 3, 1)` now reports the `+` operator rather than `INDEX`, and a per-element coercion failure reports the function alongside the argument index. [#1547](https://github.com/handsontable/hyperformula/issues/1547) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index eb0efe9cb1..1f50fbfc9c 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -47,7 +47,7 @@ a circular reference. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. * The TEXT function does not accept embedded double-quote literals in the format string. In Excel, `""` inside a format string is an escape sequence for a literal `"` character — e.g. `=TEXT(1234.5, "#,##0.00 ""zł""")` returns `"1,234.50 zł"`. If your application requires this escape sequence, supply a custom [`stringifyCurrency`](currency-handling.md) callback. -* Errors that arise inside an array result — a broadcast that has no value for a position, or a per-element type coercion that fails — report no address, and the function whose call the array is passed to may be reported as their `originFunction`. For example, `=INDEX(A1:A2+B1:B3, 3, 1)` reports `INDEX` for an `#N/A` that the `+` operator produced. Errors produced outside an array carry the identity of whatever produced them: a function or operator that rejected a value, `reference` for a reference that cannot be resolved, `removed reference` for one destroyed by removing rows or columns, `parser`, `user input`, or `literal`. `#SPILL!` and `#CYCLE!` report the affected cell with no `originFunction`, because no function produced them. +* `#SPILL!` and `#CYCLE!` errors report the cell they affect but name no `originFunction`, because no function produced them — they follow from the layout of the sheet rather than from evaluating a value. Every other cell error names what produced it: the function or operator that rejected a value, `reference` for a reference that cannot be resolved, `removed reference` for one destroyed by removing rows or columns, `parser`, `user input`, or `literal`. ### UNIQUE function diff --git a/src/interpreter/Interpreter.ts b/src/interpreter/Interpreter.ts index d1225d546c..86610f1241 100644 --- a/src/interpreter/Interpreter.ts +++ b/src/interpreter/Interpreter.ts @@ -78,6 +78,9 @@ export class Interpreter { if (val instanceof CellError) { val = stampOriginForAstNode(val, ast) } + if (val instanceof SimpleRangeValue) { + val = stampOriginForArrayElements(val, ast) + } return wrapperForRootVertex(val, state.formulaVertex) } @@ -564,6 +567,43 @@ function originNameForAstNode(ast: Ast): Maybe { * @param {CellError} val - the error produced by this evaluation * @param {Ast} ast - the node being evaluated */ +/** + * Stamps the origin function on errors sitting inside an array this node produced. + * + * An error that is one element of a returned array never reaches the scalar branch above, so + * without this it would arrive at the enclosing call unclaimed and that call would take it: an + * `#N/A` left by a broadcast, or a per-element coercion failure, would be reported as produced by + * whichever function happened to read the array. The operation that built the array is the one + * that produced them, so it is the one that signs. + * + * `withOrigin` is first-wins, so an element that already carries an origin keeps it, and the array + * is rebuilt only if at least one element actually changed. + * + * @param {SimpleRangeValue} val - the array this node produced + * @param {Ast} ast - the node being evaluated + */ +function stampOriginForArrayElements(val: SimpleRangeValue, ast: Ast): SimpleRangeValue { + const originName = originNameForAstNode(ast) + if (originName === undefined || val.range !== undefined) { + // A range-backed value is a view over cells, and an error read out of a cell was already + // marked as propagated when it was read, so there is nothing here to claim. Skipping it also + // keeps this off the hot path: reading `data` on such a value materialises the whole range. + return val + } + + let changed = false + const stamped = val.data.map(row => row.map(cell => { + if (!(cell instanceof CellError)) { + return cell + } + const withOrigin = cell.withOrigin(originName) + changed = changed || withOrigin !== cell + return withOrigin + })) + + return changed ? SimpleRangeValue.onlyValues(stamped) : val +} + function stampOriginForAstNode(val: CellError, ast: Ast): CellError { const originName = originNameForAstNode(ast) return originName === undefined ? val : val.withOrigin(originName) From e2ced02177de579cbd9a4154a508b6a8f80c3dea Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 15 Sep 2026 07:59:41 +0000 Subject: [PATCH 4/7] HF-131: an array literal signs the error its own shape produced Bugbot caught a site the previous commit missed, in the same family. An array literal whose rows do not line up builds a #REF! before any call sees it, and originNameForAstNode had no case for that node, so the enclosing call took it: =SUM({1,2;3}) reported SUM and =ABS({1,2;3}) reported ABS with an argument index that belonged to nothing. Co-Authored-By: Claude Opus 5 (1M context) --- src/interpreter/Interpreter.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/interpreter/Interpreter.ts b/src/interpreter/Interpreter.ts index 86610f1241..e45cbd69d1 100644 --- a/src/interpreter/Interpreter.ts +++ b/src/interpreter/Interpreter.ts @@ -521,6 +521,9 @@ function originNameForAstNode(ast: Ast): Maybe { switch (ast.type) { case AstNodeType.FUNCTION_CALL: return ast.procedureName + case AstNodeType.ARRAY: + // An array literal with mismatched rows builds its own error before any call sees it. + return 'array literal' case AstNodeType.CONCATENATE_OP: return 'concat' case AstNodeType.EQUALS_OP: From 547d340aea0c8547ac7d45d9956e1e194fd1d172 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 22 Oct 2026 17:19:53 +0000 Subject: [PATCH 5/7] docs: shorten the changelog entries to one sentence each Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7574498210..5666276af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed - Fixed a cell holding a static error value — a formula the parser rejected, or an error value entered directly — being reported under the address of whichever cell read it first. Such a cell now reports its own address, and keeps reporting it as rows and columns move around it. After a very long run of structural changes the original position can no longer be reconstructed, and the address is omitted rather than guessed. [#1547](https://github.com/handsontable/hyperformula/issues/1547) -- A parsing error, an error value typed directly into a cell, and an error literal written into a formula are now attributed to `parser`, `user input`, or `literal` respectively, instead of being silently unattributed or (for a parsing error) reporting no address at all. `#SPILL!` errors now report the cell that failed to spill instead of no address. [#1547](https://github.com/handsontable/hyperformula/issues/1547) -- Fixed a function or operator being reported as the producer of an error it only read. A reference that cannot be resolved now reports `reference`, and one destroyed by removing rows or columns reports `removed reference`, so `=SUM(A2:A99999999999)` no longer reports `SUM` and `=ABS(A2:A99999999999)` no longer reports an argument index that belongs to nothing. Errors that arise inside an array are attributed to the operation that built the array, so a broadcast gap in `=INDEX(A1:A2+B1:B3, 3, 1)` now reports the `+` operator rather than `INDEX`, and a per-element coercion failure reports the function alongside the argument index. [#1547](https://github.com/handsontable/hyperformula/issues/1547) +- Fixed parsing errors, error values typed into a cell and error literals in a formula having no origin, which is now `parser`, `user input` or `literal`, and `#SPILL!` errors reporting no address instead of the cell that failed to spill. [#1547](https://github.com/handsontable/hyperformula/issues/1547) +- Fixed a function or operator being reported as the producer of an error it only read: an unresolvable reference now reports `reference`, a removed one `removed reference`, and an error inside an array the operation that built the array. [#1547](https://github.com/handsontable/hyperformula/issues/1547) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) From 74ed3ceeae2cf1052d5a4b3c0b36dfc7619e0f1b Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:22:56 +0000 Subject: [PATCH 6/7] HF-131: List array literal among the origin names, and keep the changelog to released behaviour The originFunction JSDoc and the known-limitations entry now name every origin the engine produces, including `array literal`, and no longer describe two argumentIndex gaps that this change closes. The changelog drops the entries for origin behaviour that was never released, folds the origin names into the entry that adds the field, and keeps the `#SPILL!` address fix. The JSDoc of the scalar origin stamp sits above its own function again. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++-- docs/guide/known-limitations.md | 2 +- hyperformula/src/CellValue.ts | 28 ++++++++++----------- hyperformula/src/interpreter/Interpreter.ts | 22 ++++++++-------- 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b764f7400..c73d60e64c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added a message after `npm i hyperformula` that points AI assistants to the SKILLS file. The message needs `--foreground-scripts` to be visible in npm, and pnpm 10 needs the `postinstall` script to be approved first. [#1829](https://github.com/handsontable/hyperformula/pull/1829) - Added `DetailedCellError.hasMessage`, so a consumer can tell a cell error that never carried a message (e.g. a custom function that omitted one) apart from one with a deliberately empty message — both previously surfaced identically as `message: ''`. [#1547](https://github.com/handsontable/hyperformula/issues/1547) -- Added `DetailedCellError.originFunction` and `DetailedCellError.argumentIndex`, which name the function or operator that produced a cell error and, when it failed to coerce an argument, that argument's position as written in the formula. [#1547](https://github.com/handsontable/hyperformula/issues/1547) +- Added `DetailedCellError.originFunction` and `DetailedCellError.argumentIndex`, which name what produced a cell error (a function, an operator, or a source such as `reference` or `parser`) and, when it failed to coerce an argument, that argument's position as written. [#1547](https://github.com/handsontable/hyperformula/issues/1547) ### Changed @@ -22,8 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Fixed the `EDATE` function returning the 28th of February instead of the 29th when the shifted date falls in February of a leap year (e.g. `=EDATE(DATE(2028, 1, 31), 1)` now returns `2028-02-29` instead of `2028-02-28`). [#1781](https://github.com/handsontable/hyperformula/pull/1781) - Fixed a cell holding a static error value, such as a rejected formula or a typed-in error, being reported under the address of the cell that read it first instead of its own address. [#1547](https://github.com/handsontable/hyperformula/issues/1547) -- Fixed parsing errors, error values typed into a cell and error literals in a formula having no origin, which is now `parser`, `user input` or `literal`, and `#SPILL!` errors reporting no address instead of the cell that failed to spill. [#1547](https://github.com/handsontable/hyperformula/issues/1547) -- Fixed a function or operator being reported as the producer of an error it only read: an unresolvable reference now reports `reference`, a removed one `removed reference`, and an error inside an array the operation that built the array. [#1547](https://github.com/handsontable/hyperformula/issues/1547) +- Fixed `#SPILL!` errors reporting no address; their `address` is now the cell whose array result could not spill. [#1547](https://github.com/handsontable/hyperformula/issues/1547) ## [3.5.0] - 2026-10-09 diff --git a/docs/guide/known-limitations.md b/docs/guide/known-limitations.md index 258da56496..5bca16c1e2 100644 --- a/docs/guide/known-limitations.md +++ b/docs/guide/known-limitations.md @@ -47,7 +47,7 @@ a circular reference. * The FILTER function accepts either single rows of equal width or single columns of equal height. In other words, all arrays passed to the FILTER function must have equal dimensions, and at least one of those dimensions must be 1. * Array-producing functions (e.g., SEQUENCE, FILTER) require their output dimensions to be determinable at parse time. Passing cell references or formulas as dimension arguments (e.g., `=SEQUENCE(A1)`) results in a `#VALUE!` error, because the output size cannot be resolved before evaluation. * The TEXT function does not accept embedded double-quote literals in the format string. In Excel, `""` inside a format string is an escape sequence for a literal `"` character — e.g. `=TEXT(1234.5, "#,##0.00 ""zł""")` returns `"1,234.50 zł"`. If your application requires this escape sequence, supply a custom [`stringifyCurrency`](currency-handling.md) callback. -* `#SPILL!` and `#CYCLE!` errors report the cell they affect but name no `originFunction`, because no function produced them — they follow from the layout of the sheet rather than from evaluating a value. Every other cell error names what produced it: the function or operator that rejected a value, `reference` for a reference that cannot be resolved, `removed reference` for one destroyed by removing rows or columns, `parser`, `user input`, or `literal`. +* `#SPILL!` and `#CYCLE!` errors report the cell they affect but name no `originFunction`, because no function produced them — they follow from the layout of the sheet rather than from evaluating a value. Every other cell error names what produced it: the function or operator that rejected a value, `reference` for a reference that cannot be resolved, `removed reference` for one destroyed by removing rows or columns, `parser`, `user input`, `literal`, or `array literal`. ### UNIQUE function diff --git a/hyperformula/src/CellValue.ts b/hyperformula/src/CellValue.ts index 412bf8056c..4466c4c741 100644 --- a/hyperformula/src/CellValue.ts +++ b/hyperformula/src/CellValue.ts @@ -25,16 +25,22 @@ export class DetailedCellError { public readonly hasMessage: boolean /** - * What produced this error: the function or operator that rejected a value, e.g. `'SQRT'` - * or `'divide'`. An operator is named after the arithmetic helper it dispatches to. + * What produced this error. * - * First occurrence wins, so a function that only received the error from a nested call - * never replaces it: `=SUM(SQRT(-1))` reports `'SQRT'`. An error read from another cell - * keeps the origin it had there. + * Usually the function or operator that rejected a value, e.g. `'SQRT'` or `'divide'`; an + * operator is named after the arithmetic helper it dispatches to. An error that exists + * before any function reads it names what built it instead: `'reference'` for a reference + * that cannot be resolved, `'removed reference'` for one destroyed by removing rows or + * columns, `'parser'` for a formula that could not be parsed, `'user input'` for an error + * value typed into a cell, `'literal'` for one written into a formula, and + * `'array literal'` for an array literal whose rows differ in length. * - * `undefined` when no function or operator produced the error, e.g. for `#SPILL!`, - * `#CYCLE!`, a parsing error, or an error value typed into a cell. An error from a - * reference the engine cannot resolve is attributed to the function that reads it. + * First occurrence wins, so a function that only read the error never replaces that: + * `=SUM(SQRT(-1))` reports `'SQRT'`, and `=SUM(A2:A99999999999)` reports `'reference'`. + * An error inside an array result names the operation that built the array. + * + * `undefined` for `#SPILL!` and `#CYCLE!`, which arise from the layout of a sheet rather + * than from evaluating a value. */ public readonly originFunction?: string @@ -57,12 +63,6 @@ export class DetailedCellError { * is the argument the user wrote: `=MULTINOMIAL(1,A1:A3)` with text in `A3` reports `1`. * The index names the argument to look at, not the cell; the address of the offending * cell is not part of this field. - * - * Two cases report an index that is less useful than it looks: an argument that is a - * reference the engine cannot resolve is claimed by the function reading it - * (`=DATE(2020,A99999999999,1)` reports `DATE` with index `1`), and an error raised per - * element while a function is applied across an array carries an index with no - * `originFunction` (`=DATE(2020,A1:A2,1)` over text, with array arithmetic on). */ public readonly argumentIndex?: number diff --git a/hyperformula/src/interpreter/Interpreter.ts b/hyperformula/src/interpreter/Interpreter.ts index b7827d7dd7..41195f7190 100644 --- a/hyperformula/src/interpreter/Interpreter.ts +++ b/hyperformula/src/interpreter/Interpreter.ts @@ -564,17 +564,6 @@ function originNameForAstNode(ast: Ast): Maybe { } } -/** - * Stamps the origin function on an error, once per evaluation. - * - * Placed in `evaluateAst`'s single postprocessing tail rather than in each `switch` case, so it - * also catches the overflow guard's freshly-minted error, and so no case can be forgotten. Every - * nested sub-expression runs its own `evaluateAst` first, so an inner error is always stamped - * before an outer function could overwrite it — and `withOrigin` is a no-op once set anyway. - * - * @param {CellError} val - the error produced by this evaluation - * @param {Ast} ast - the node being evaluated - */ /** * Stamps the origin function on errors sitting inside an array this node produced. * @@ -612,6 +601,17 @@ function stampOriginForArrayElements(val: SimpleRangeValue, ast: Ast): SimpleRan return changed ? SimpleRangeValue.onlyValues(stamped) : val } +/** + * Stamps the origin function on an error, once per evaluation. + * + * Placed in `evaluateAst`'s single postprocessing tail rather than in each `switch` case, so it + * also catches the overflow guard's freshly-minted error, and so no case can be forgotten. Every + * nested sub-expression runs its own `evaluateAst` first, so an inner error is always stamped + * before an outer function could overwrite it — and `withOrigin` is a no-op once set anyway. + * + * @param {CellError} val - the error produced by this evaluation + * @param {Ast} ast - the node being evaluated + */ function stampOriginForAstNode(val: CellError, ast: Ast): CellError { const originName = originNameForAstNode(ast) return originName === undefined ? val : val.withOrigin(originName) From 121d0e79aed7e19fd59c3b2b2f24eddbe5c9a866 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 23 Oct 2026 00:26:39 +0000 Subject: [PATCH 7/7] HF-131: Name the reference, not the aggregate function, for a range that spans sheets or a missing sheet `SUM`, `MAX` and the other aggregates read a range through their own path, which built the `#REF!` for a range spanning several sheets or pointing at a missing sheet without an origin, so the aggregate claimed it. Both errors now report `reference`, like the same errors built by the interpreter. Co-Authored-By: Claude Opus 5.5 --- .../src/interpreter/plugin/NumericAggregationPlugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hyperformula/src/interpreter/plugin/NumericAggregationPlugin.ts b/hyperformula/src/interpreter/plugin/NumericAggregationPlugin.ts index 2bb056a758..0ddbf7d3bd 100644 --- a/hyperformula/src/interpreter/plugin/NumericAggregationPlugin.ts +++ b/hyperformula/src/interpreter/plugin/NumericAggregationPlugin.ts @@ -587,14 +587,14 @@ export class NumericAggregationPlugin extends FunctionPlugin implements Function range = AbsoluteCellRange.fromAst(ast, state.formulaAddress) } catch (err) { if (err instanceof SheetsNotEqual) { - return new CellError(ErrorType.REF, ErrorMessage.RangeManySheets) + return new CellError(ErrorType.REF, ErrorMessage.RangeManySheets).withOrigin('reference') } else { throw err } } if (!this.isSheetValid(range)) { - return new CellError(ErrorType.REF, ErrorMessage.SheetRef) + return new CellError(ErrorType.REF, ErrorMessage.SheetRef).withOrigin('reference') } const rangeVertex = this.dependencyGraph.getRange(range.start, range.end)