Summary
Numscript parse and runtime errors already carry fully structured diagnostics — message plus start/end line and character — and the API throws that structure away at the HTTP boundary. Clients receive a single terminal-formatted string in errorMessage and an empty details, so a caller that wants to highlight the offending span (an editor, a playground, a form that submits Numscript) has to parse ASCII caret art to recover positions that the server already had.
Follow-up to #1746, which corrects the documentation of details. This issue is about giving the field something useful to carry.
What the parser produces
numscript.ParserError is structured (internal/parser/parser.go:13, re-exported at numscript.go:38):
type ParserError struct {
Range // Start, End Position{ Character, Line int }
Msg string
}
and reaches the controller layer intact (internal/controller/ledger/errors.go:184):
type ErrParsing struct {
Source string
Errors []numscript.ParserError // precondition: not empty
}
Runtime errors carry position too: InterpreterError is error + parser.Ranged (numscript/internal/interpreter/interpreter.go:89-92), so ErrRuntime (errors.go:170) has a Range available via GetRange().
Where it is lost
Every Numscript error path calls plain api.BadRequest, which never populates details (json:"details,omitempty"):
internal/api/v2/controllers_transactions_create.go:63 — ErrParsing → api.BadRequest(w, common.ErrInterpreterParse, err)
internal/api/v2/controllers_transactions_create.go:65 — ErrRuntime → api.BadRequest(w, common.ErrInterpreterRuntime, err)
internal/api/v2/controllers_transactions_create.go:56 — ErrCompilationFailed / ErrInvalidVars → api.BadRequest(w, common.ErrCompilationFailed, err)
internal/api/bulking/handler_json.go:145,153 — same mapping in the bulk path
Because only Error() survives, ErrParsing collapses to numscript.ParseErrorsToString(e.Errors, e.Source) (errors.go:190), and the array of positions is gone.
The string that replaces it
ParseErrorsToString (numscript/internal/parser/parser.go:83) builds err.Msg + "\n" + err.ShowOnSource(source) per error, and ShowOnSource (range.go:43) renders a terminal diagnostic: a %3d line-number gutter, a " | " separator, the source line, and a caret underline beneath the offending span.
That is CLI output, and it ends up as the value of a JSON errorMessage. Two consequences:
- It is multi-line ASCII art in a field the API documents as a human-readable message that clients must not parse — yet parsing it is currently the only way to recover a line number.
- It embeds the submitted source back into the message, which inflates the error body for large scripts and duplicates data the caller already has.
Proposal
Options, roughly in order of how much I'd argue for them:
A — new structured field (preferred). Add an optional array alongside details, e.g.
{
"errorCode": "INTERPRETER_PARSE",
"errorMessage": "parsing failed: 2 errors",
"diagnostics": [
{ "message": "unexpected token '('", "start": { "line": 3, "character": 12 }, "end": { "line": 3, "character": 13 } }
]
}
Additive and non-breaking, maps 1:1 onto what the parser already returns, and lets errorMessage become short and stable. Costs a schema change across openapi/v{1,2,3}.yaml and an SDK regeneration.
B — JSON-encode into details. No schema change, since details already exists as a string. But JSON-inside-a-string is unpleasant to consume and would make details mean something different per error code, which is the kind of ambiguity #1746 is cleaning up.
C — minimal. Keep errorMessage as the short reason, move the rendered multi-line diagnostic into details as plain text. Does not expose positions, but it at least stops caret art from being the primary error message, and matches what details is for.
If a structured field is out of scope, C alone is still a net improvement and is nearly free.
Notes on scope
COMPILATION_FAILED is the weakest case: it comes from the legacy compiler.Compile path (internal/controller/ledger/numscript_parser.go:26) and wraps an opaque error, so there may be no positions to surface. Worth confirming before promising anything for that code.
INTERPRETER_PARSE is the strongest and could ship on its own — the data is already there, typed, with a documented non-empty precondition.
- Whatever is chosen should apply to the bulk path too (
handler_json.go), otherwise the same script fails differently depending on how it was submitted.
Context
Surfaced while correcting the API errors reference page in formancehq/docs#168, which had claimed details carries the Numscript diagnostics. It does not carry anything — but the diagnostics genuinely exist one layer down, which is what makes this worth doing rather than just documenting the absence. Raised separately from #1746 because that one is a doc fix and this is a behaviour change.
Summary
Numscript parse and runtime errors already carry fully structured diagnostics — message plus start/end line and character — and the API throws that structure away at the HTTP boundary. Clients receive a single terminal-formatted string in
errorMessageand an emptydetails, so a caller that wants to highlight the offending span (an editor, a playground, a form that submits Numscript) has to parse ASCII caret art to recover positions that the server already had.Follow-up to #1746, which corrects the documentation of
details. This issue is about giving the field something useful to carry.What the parser produces
numscript.ParserErroris structured (internal/parser/parser.go:13, re-exported atnumscript.go:38):and reaches the controller layer intact (
internal/controller/ledger/errors.go:184):Runtime errors carry position too:
InterpreterErroriserror + parser.Ranged(numscript/internal/interpreter/interpreter.go:89-92), soErrRuntime(errors.go:170) has aRangeavailable viaGetRange().Where it is lost
Every Numscript error path calls plain
api.BadRequest, which never populatesdetails(json:"details,omitempty"):internal/api/v2/controllers_transactions_create.go:63—ErrParsing→api.BadRequest(w, common.ErrInterpreterParse, err)internal/api/v2/controllers_transactions_create.go:65—ErrRuntime→api.BadRequest(w, common.ErrInterpreterRuntime, err)internal/api/v2/controllers_transactions_create.go:56—ErrCompilationFailed/ErrInvalidVars→api.BadRequest(w, common.ErrCompilationFailed, err)internal/api/bulking/handler_json.go:145,153— same mapping in the bulk pathBecause only
Error()survives,ErrParsingcollapses tonumscript.ParseErrorsToString(e.Errors, e.Source)(errors.go:190), and the array of positions is gone.The string that replaces it
ParseErrorsToString(numscript/internal/parser/parser.go:83) buildserr.Msg + "\n" + err.ShowOnSource(source)per error, andShowOnSource(range.go:43) renders a terminal diagnostic: a%3dline-number gutter, a" | "separator, the source line, and a caret underline beneath the offending span.That is CLI output, and it ends up as the value of a JSON
errorMessage. Two consequences:Proposal
Options, roughly in order of how much I'd argue for them:
A — new structured field (preferred). Add an optional array alongside
details, e.g.{ "errorCode": "INTERPRETER_PARSE", "errorMessage": "parsing failed: 2 errors", "diagnostics": [ { "message": "unexpected token '('", "start": { "line": 3, "character": 12 }, "end": { "line": 3, "character": 13 } } ] }Additive and non-breaking, maps 1:1 onto what the parser already returns, and lets
errorMessagebecome short and stable. Costs a schema change acrossopenapi/v{1,2,3}.yamland an SDK regeneration.B — JSON-encode into
details. No schema change, sincedetailsalready exists as astring. But JSON-inside-a-string is unpleasant to consume and would makedetailsmean something different per error code, which is the kind of ambiguity #1746 is cleaning up.C — minimal. Keep
errorMessageas the short reason, move the rendered multi-line diagnostic intodetailsas plain text. Does not expose positions, but it at least stops caret art from being the primary error message, and matches whatdetailsis for.If a structured field is out of scope, C alone is still a net improvement and is nearly free.
Notes on scope
COMPILATION_FAILEDis the weakest case: it comes from the legacycompiler.Compilepath (internal/controller/ledger/numscript_parser.go:26) and wraps an opaque error, so there may be no positions to surface. Worth confirming before promising anything for that code.INTERPRETER_PARSEis the strongest and could ship on its own — the data is already there, typed, with a documented non-empty precondition.handler_json.go), otherwise the same script fails differently depending on how it was submitted.Context
Surfaced while correcting the API errors reference page in formancehq/docs#168, which had claimed
detailscarries the Numscript diagnostics. It does not carry anything — but the diagnostics genuinely exist one layer down, which is what makes this worth doing rather than just documenting the absence. Raised separately from #1746 because that one is a doc fix and this is a behaviour change.