jsonatapy 2.2.7__tar.gz → 2.2.8__tar.gz

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 (69) hide show
  1. jsonatapy-2.2.8/CHANGELOG.md +866 -0
  2. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/Cargo.lock +28 -28
  3. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/Cargo.toml +3 -3
  4. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/PKG-INFO +44 -13
  5. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/README.md +42 -11
  6. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/bindings/c/examples/smoke.c +4 -1
  7. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/pyproject.toml +2 -2
  8. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/python/jsonatapy/__init__.py +14 -1
  9. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/ast.rs +17 -4
  10. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/ast_transform.rs +15 -5
  11. jsonatapy-2.2.8/src/builtins.rs +1747 -0
  12. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/capi.rs +8 -3
  13. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/compiler.rs +1 -1
  14. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/datetime.rs +86 -26
  15. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/evaluator.rs +1513 -3255
  16. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/functions.rs +339 -179
  17. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/lib.rs +115 -18
  18. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/parser.rs +181 -46
  19. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/signature.rs +308 -22
  20. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/value.rs +1 -1
  21. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/vm.rs +32 -18
  22. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/study/cli_fixtures.json +1 -1
  23. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/tests/cli_test.rs +5 -1
  24. jsonatapy-2.2.8/tests/fixtures/builtin_arity.json +1 -0
  25. jsonatapy-2.2.8/tests/fixtures/builtin_differential.json +1 -0
  26. jsonatapy-2.2.8/tests/fixtures/builtin_signatures.json +65 -0
  27. jsonatapy-2.2.8/tests/fixtures/fastpath_differential.json +1 -0
  28. jsonatapy-2.2.8/tests/fixtures/fastpath_known_divergences.json +5 -0
  29. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/tests/integration_test.rs +1242 -23
  30. jsonatapy-2.2.7/CHANGELOG.md +0 -362
  31. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/.gitignore +0 -0
  32. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/.gitmodules +0 -0
  33. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/.serena/.gitignore +0 -0
  34. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/.serena/memories/architecture.md +0 -0
  35. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/.serena/memories/conventions.md +0 -0
  36. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/.serena/memories/core.md +0 -0
  37. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/.serena/memories/memory_maintenance.md +0 -0
  38. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/.serena/memories/suggested_commands.md +0 -0
  39. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/.serena/memories/task_completion.md +0 -0
  40. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/.serena/memories/tech_stack.md +0 -0
  41. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/.serena/project.yml +0 -0
  42. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/LICENSE +0 -0
  43. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/benches/evaluator_bench.rs +0 -0
  44. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/bindings/c/README.md +0 -0
  45. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/bindings/c/jsonata.h +0 -0
  46. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/examples/evaluator_demo.rs +0 -0
  47. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/examples/host_functions.rs +0 -0
  48. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/examples/parser_demo.rs +0 -0
  49. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/examples/simd_json_bench.rs +0 -0
  50. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/python/jsonatapy/__main__.py +0 -0
  51. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/python/jsonatapy/_cli/__init__.py +0 -0
  52. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/python/jsonatapy/_cli/bindings.py +0 -0
  53. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/python/jsonatapy/_cli/error_format.py +0 -0
  54. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/python/jsonatapy/_cli/mcp_server.py +0 -0
  55. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/python/jsonatapy/_cli/resolve.py +0 -0
  56. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/python/jsonatapy/_cli/run.py +0 -0
  57. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/python/jsonatapy/py.typed +0 -0
  58. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/bin/jsonata/bindings.rs +0 -0
  59. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/bin/jsonata/error_format.rs +0 -0
  60. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/bin/jsonata/main.rs +0 -0
  61. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/bin/jsonata/resolve.rs +0 -0
  62. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/lazy.rs +0 -0
  63. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/src/parser/README.md +0 -0
  64. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/study/cli_fixtures_testdata.json +0 -0
  65. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/study/cli_spec.md +0 -0
  66. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/tests/cli_fixtures_test.rs +0 -0
  67. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/tests/datetime_picture_suite.rs +0 -0
  68. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/tests/host_functions_test.rs +0 -0
  69. {jsonatapy-2.2.7 → jsonatapy-2.2.8}/tests/parent_and_focus_binding_suite.rs +0 -0
@@ -0,0 +1,866 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ ### Changed
13
+
14
+ ### Deprecated
15
+
16
+ ### Removed
17
+
18
+ ### Fixed
19
+
20
+ ### Security
21
+
22
+ ## [2.2.8] "Conform-ata" - 2026-08-25
23
+
24
+ Primarily a conformance release, with documentation work on the Rust crate alongside it. Most
25
+ of what follows brings `jsonatapy` closer to the pinned jsonata-js (v2.2.2), and a fair amount
26
+ of it **changes the answer** for expressions that already run today. If you are upgrading, the
27
+ summary below is the part worth reading; the itemised entries follow.
28
+
29
+ ### What changed, and who it affects
30
+
31
+ **Boolean coercion of containers — the broadest change.** JSONata has one truthiness rule and
32
+ applies it recursively: a container is truthy only if some element is truthy, all the way down.
33
+ `jsonatapy` had three rules, and the one every implicit consumer used asked only "is it
34
+ non-empty?". A single-element array holding a falsy value — `[0]`, `[""]`, `[false]`, `[null]` —
35
+ was therefore truthy where the reference says false. That affects `? :`, `and`, `or`, `$not`,
36
+ filter predicates, `$filter`, and any lambda whose result is coerced.
37
+
38
+ *You are exposed if* your data holds one-element arrays of falsy values (`{"flags": [false]}`,
39
+ `{"counts": [0]}`) or you build them (`[expr]`, `expr[]`, `$map`, `$filter`). Ordinary path
40
+ expressions are unaffected — a path matching one value unwraps to the scalar, so
41
+ `items[v=0].v` was already `0`, not `[0]`.
42
+
43
+ **`$formatNumber` produces different numbers.** Rounding was half-away-from-zero; the reference
44
+ rounds half-to-even. `$formatNumber(12.345, "#,##0.00")` was `"12.35"` and is now `"12.34"`.
45
+ Three further defects around empty fractional parts, optional digits and exponents are fixed in
46
+ the same pass.
47
+
48
+ *You are exposed if* you format numbers for display or comparison and depend on the previous
49
+ rounding.
50
+
51
+ **Sequence and null handling in paths.** `*` on a value with no children is `undefined` rather
52
+ than `null`; `*` and `#$i` now unwrap a lone result like every other sequence-producing step;
53
+ an explicit null is treated as a value by object construction and `[]` rather than
54
+ short-circuiting them; and `[]` and `[true]` are finally distinct operators.
55
+
56
+ *You are exposed if* you rely on the shape (wrapped vs unwrapped) of single-result expressions,
57
+ or on `null` where the reference produces `undefined` — the two differ in object construction,
58
+ which drops undefined-valued keys but keeps null-valued ones.
59
+
60
+ **Stricter argument validation.** Six builtins (`$base64encode`, `$base64decode`, `$toMillis`,
61
+ `$fromMillis`, `$formatInteger`, `$parseInteger`) had no signature at all and so validated
62
+ nothing. They now reject an explicit null with `T0410` where they previously returned `null`,
63
+ and they accept the context forms (`str.$base64encode()`) that never worked.
64
+
65
+ *You are exposed if* you pass a possibly-null value to any of those six and relied on getting
66
+ `null` back rather than an error.
67
+
68
+ **More permissive parsing in two places.** `$base64decode` now accepts what the reference
69
+ accepts — partial quanta, characters outside the alphabet, the URL-safe alphabet — instead of
70
+ raising. `$parseInteger` returns `NaN` for a value that does not match its picture rather than
71
+ raising `D3136`.
72
+
73
+ *You are exposed if* you depended on those raising to detect bad input.
74
+
75
+ **Error codes.** This is the largest single change by count. Errors across the evaluator and the
76
+ parser reported the wrong JSONata code, or none at all — an error with no code is not something
77
+ a caller can branch on. Of the reference suite's 273 code-bearing cases, 107 could not be
78
+ verified at 2.2.7 because our error carried no code to compare; that is now 2, and those two are
79
+ unreachable rather than unfixed. Parse errors in particular went from uncoded prose to carrying
80
+ `S0101`, `S0103`, `S0104`, `S0105`, `S0202`, `S0203`, `S0204`, `S0207`, `S0208`, `S0211`, `S0212`
81
+ and `S0401`, so a syntax error is now distinguishable from any other failure.
82
+
83
+ **Things that used to fail and now work.** Passing a builtin by reference through a variable
84
+ (`$f := $uppercase; $map(arr, $f)`) returned empty strings and now works, for every builtin.
85
+ `$eval` works as a callback. `$single` is recognised when passed by reference. Twenty-four
86
+ builtins that worked in direct calls but raised when passed to `$map`/`$filter` now behave
87
+ identically either way.
88
+
89
+ **Documentation — the Rust crate.** Its examples were marked `rust,ignore`, so nothing verified
90
+ they were correct; they are now four compiling doctests. Five rustdoc warnings that rendered as
91
+ broken links on docs.rs are gone, the crate header no longer names the wrong crate, and the
92
+ module list no longer advertises two private modules while omitting two feature-gated ones. On
93
+ the Python side, the package is classified Production/Stable rather than Beta — it still said
94
+ Beta at full reference-suite parity. Itemised under Added, Changed and Fixed below.
95
+
96
+ **Internals with no behavioural intent.** Builtin dispatch is now a single shared
97
+ implementation rather than three partial copies, and the differential harness gained the
98
+ ability to see distinctions it was previously blind to — `null` vs `undefined`, error codes,
99
+ and by-reference dispatch of evaluator-dependent builtins. Each of those blind spots was
100
+ hiding real divergences, which is where most of this release came from.
101
+
102
+ ### Compatibility
103
+
104
+ The differential corpus — over 20,000 comparisons against jsonata-js across two engines and two
105
+ input paths — has **no known divergences** for the first time. The one deliberate exception is
106
+ base64's character set, documented below.
107
+
108
+ All 1686 reference-suite cases pass, and each now runs through **both** evaluation engines —
109
+ the suite previously ran only whichever the default resolved to, so the tree-walker was
110
+ exercised by the differential corpus and nowhere else.
111
+
112
+ Worth stating plainly rather than quoting the pass count: 43 of its error-expecting cases still
113
+ assert only that *something* was raised, because the errors we produce for them carry no JSONata
114
+ code to compare, and 15 more specify an error object that is not inspected. That is
115
+ [#144](https://github.com/txjmb/jsonata-core/issues/144), and it is mostly parser errors. It is
116
+ a gap in what the suite verifies, not a set of known failures — a test now pins the count so it
117
+ cannot grow unnoticed.
118
+
119
+
120
+ ### Added
121
+ - The `jsonata-core` crate documentation now carries four compiling examples — quick start,
122
+ compile-once/evaluate-many, error handling, and host functions via `register_fn`. The
123
+ crate-level example was previously marked `rust,ignore`, so nothing verified it was correct;
124
+ `cargo test --doc` now runs 5 doctests.
125
+
126
+ ### Changed
127
+ - The published Python package is now classified `Development Status :: 5 - Production/Stable`.
128
+ It still read `4 - Beta` at full reference-suite parity, and that classifier is the only
129
+ signal the PyPI sidebar and downstream trend trackers read.
130
+ - Every builtin that needs only its arguments is now implemented once, in `src/builtins.rs`,
131
+ and shared by the compiled path and the tree-walker instead of being written out in each.
132
+ Fifty-three builtins were spread across two dispatch sites: twenty-nine were implemented
133
+ twice, and twenty-four existed in exactly one, which is why `$type(x)` worked while
134
+ `$map(arr, $type)` raised. The differential corpus and the 1686-case reference suite
135
+ confirm the extraction preserves *value* and *error* behaviour for every builtin other
136
+ than the six listed under Fixed below. Both harnesses collapse `null` and `undefined` to
137
+ Python `None`, so that axis is confirmed only where the corpus's new object-construction
138
+ probes cover it directly (an undefined-valued key is dropped, a null-valued one is kept).
139
+ `$string()` against an explicit-null context is a further route disagreement at the
140
+ pre-branch baseline: the compiled path answered `undefined`, the tree-walker answered
141
+ `null`. The shared dispatcher answers `null` on both. `evaluate_function_call` drops from
142
+ 2688 lines to 1266.
143
+ (issue [#107](https://github.com/txjmb/jsonata-core/issues/107))
144
+
145
+ ### Deprecated
146
+
147
+ ### Removed
148
+
149
+ ### Fixed
150
+ - The last uncoded errors carry their code: calling a non-function is `T1006`, partially
151
+ applying one (a call carrying a `?` placeholder) is `T1008`, assigning to something that is
152
+ not a variable is `S0212`, and a type parameter applied to something other than a function or
153
+ array is `S0401`. Every reference case that names an error code now has that code compared
154
+ against ours, except two that cannot be reached at all — `$encodeUrl` on an unpaired
155
+ surrogate, where the expression never crosses into Rust to be parsed.
156
+ (issue [#144](https://github.com/txjmb/jsonata-core/issues/144))
157
+ - Parse errors carry their JSONata code. Unterminated strings are `S0101`, unsupported escapes
158
+ `S0103`, a malformed `\u` escape `S0104`, an unterminated backquoted name `S0105`, a wrong
159
+ token `S0202`, running out of input while expecting one `S0203`, an unknown operator `S0204`,
160
+ an unexpected end of expression `S0207`, and a symbol used where an operand belongs `S0211`.
161
+ Previously all of these were uncoded prose, so callers branching on error codes could not
162
+ distinguish a syntax error from any other failure.
163
+ (issue [#144](https://github.com/txjmb/jsonata-core/issues/144))
164
+ - Nine more evaluator errors carry their JSONata code: `$sqrt` of a negative number is `D3060`,
165
+ `$power` overflowing is `D3061`, the single-argument `$sort` on mixed types is `D3070`,
166
+ `$single` matching more than one value is `D3138`, a non-string object key is `T1003`, a
167
+ non-function right side of `~>` is `T2006`, and `$split` given a matcher function that does
168
+ not produce the expected structure is `T1010`. Calling a function without its `$` splits the
169
+ way jsonata-js splits it: a name that *is* a builtin gets `T1005` with the "did you mean
170
+ `$name`?" suggestion, anything else gets `T1006`.
171
+ (issue [#144](https://github.com/txjmb/jsonata-core/issues/144))
172
+ - `$decodeUrl` and `$decodeUrlComponent` report `D3140` on malformed input, naming the function
173
+ and quoting the value as jsonata-js does, instead of an uncoded "Invalid percent-encoded URL".
174
+ - An expression containing an unpaired surrogate no longer leaks a raw `UnicodeEncodeError`. A
175
+ Python `str` can hold one and a Rust `String` cannot, so such an expression cannot cross the
176
+ boundary to be parsed at all; PyO3's codec error now becomes a `ValueError` naming the
177
+ surrogate and its position, so the library keeps a single error type. (`$encodeUrl` on a lone
178
+ surrogate is `D3140` upstream; there is no point at which we could raise it, since the
179
+ expression never parses.)
180
+ - The reference suite compares the `code` of cases that specify an error *object*, not just
181
+ those with a bare `code` field. Nothing about those 15 was checked before.
182
+ - `$.7a` now raises `S0201`, not `S0213`. jsonata-js raises `S0213` ("literal value cannot be
183
+ used as a step") from a pass that runs *after* parsing, so an unexpected trailing token fails
184
+ the parse first; we raised it inline, before the trailing token was reached. Our `S0213` was
185
+ already right for `$.7` and `a.7` — only the ordering was wrong. A leftover token now also
186
+ carries `S0201`, the code the reference uses for it, instead of an uncoded "Expected end of
187
+ expression".
188
+ - Every reference-suite case now runs through both evaluation engines, and the suite pins how
189
+ many of its error cases it cannot actually verify. At 2.2.7 four cases had the two engines
190
+ raising different errors, each accepted because both were errors and the check was loose.
191
+ - The test reporter no longer crashes the run with an `INTERNALERROR` when a non-parametrized
192
+ test fails; it read `item.callspec` unguarded on the failure path.
193
+ - The reference-suite harness compares error codes it previously ignored. `extract_error_code`
194
+ was anchored to the start of the message, so any coded error carrying a prefix — `"Runtime
195
+ error: D3030: ..."`, `"Parse error: Invalid syntax: S0209: ..."` — read as *uncoded*, and an
196
+ uncoded error is accepted for any expected code. 36 cases that emit exactly the right code
197
+ were passing without it ever being compared, and one emitting the wrong code passed the same
198
+ way. Now 228 of the 273 code-expecting cases are genuinely compared, up from 191.
199
+ (issue [#144](https://github.com/txjmb/jsonata-core/issues/144))
200
+ - Five rustdoc warnings that rendered as broken links on docs.rs are gone. JSONata syntax in
201
+ doc comments — `[expr]`, `|location|update[,delete]|` — was being parsed as intra-doc links,
202
+ and `Rc<str>` as an unclosed HTML tag. `cargo doc` is now warning-free under both the default
203
+ features and `--all-features`.
204
+ - The crate's documentation header said `jsonatapy` (the crate is `jsonata-core`), and its
205
+ module list advertised `datetime` and `signature` as public when both are private while
206
+ omitting the feature-gated `lazy` and `capi`.
207
+ - A positional predicate that names the same index more than once now repeats the element, as
208
+ jsonata-js does: `nums[[0,0]]` is `[10, 10]`, not `10`. The reference walks the selector array
209
+ and pushes the item on every hit; we used an `any()`-style membership test, which can only ever
210
+ yield each element once. Singleton sequences repeat too — `num[[0,0]]` is `[5, 5]`.
211
+ - The tree-walker now recognises an *array* of indices as a positional selector against a scalar.
212
+ It tested only for a single number, so `num[[0]]` was `undefined` and `num[[1]]` was `5` — both
213
+ inverted, because the array fell through to the truthiness branch where an all-falsy container
214
+ is falsy and a non-empty one is truthy. The compiled path was already correct, so the two
215
+ engines disagreed.
216
+ - `$eval` can be passed by reference: `$map(["1+1"], $eval)` is `2` rather than an error. It is
217
+ the one evaluator-dependent builtin whose arguments are ordinary values — an expression string
218
+ and an optional focus — so unlike the other nine it runs from evaluated values. Its
219
+ implementation is now shared by the two dispatch paths rather than living only in
220
+ `evaluate_function_call`.
221
+ - The other nine evaluator-dependent builtins (`$map`, `$filter`, `$reduce`, `$single`, `$sift`,
222
+ `$each`, `$sort`, `$match`, `$replace`) now raise `T0410` when handed to a higher-order
223
+ function, matching jsonata-js, instead of an uncoded internal error. They still cannot run
224
+ as callbacks — each needs a *function* argument and a callback receives a value and an index,
225
+ which the reference rejects on the signature too — so this is the error code, not the
226
+ behaviour.
227
+ - `$single` is recognised as a builtin when passed by reference. The internal name list carried
228
+ `singletonArray`, which is not a JSONata function and appears nowhere in jsonata-js, and
229
+ omitted `single` — so `$map(arr, $single)` raised "Argument 2 must be Function" while direct
230
+ calls worked. (issue [#140](https://github.com/txjmb/jsonata-core/issues/140))
231
+ - `$eval(null)` now raises `T0410` rather than returning `null`. The reference's signature is
232
+ `<sx?:x>`, and `s` admits *missing* but not null; `$eval(nothing)` still propagates undefined.
233
+ - Truthiness is now one rule, applied recursively, matching `$boolean`. JSONata coerces a
234
+ value to boolean the same way everywhere: a container is truthy only if some element is
235
+ truthy, checked all the way down, so `[0]`, `[[0]]` and `[0,0]` are falsy. `jsonata-core`
236
+ had three rules — `$boolean` (correct), a flat "is it non-empty?" used by every implicit
237
+ consumer, and a third half-recursive one used only by `?:`. `$not([0])` was `false` where
238
+ `$boolean([0])` was already `false`, so `$not` was not computing `!$boolean(x)`. This
239
+ changes the answer for `$not`, `? :`, `and`, `or`, filter predicates, `$filter` and any
240
+ lambda whose result is coerced — 54 measured cases across those seven consumers — whenever
241
+ the value is a container whose contents are all falsy. The `?:`-only rule is deleted rather
242
+ than realigned: with `is_truthy` correct the two agree exactly.
243
+ (issue [#111](https://github.com/txjmb/jsonata-core/issues/111))
244
+ - `$formatNumber` now produces the same value as jsonata-js. Four separate defects, all in
245
+ picture analysis and formatting:
246
+ - **Rounding was half-away-from-zero, not half-to-even.** The reference routes this through
247
+ the same helper `$round` uses. `$formatNumber(12.345, "#,##0.00")` was `"12.35"` and is
248
+ `"12.34"`; `$formatNumber(0.25, "0.0")` was `"0.3"` and is `"0.2"`. Our `$round` builtin
249
+ was already correct — `$formatNumber` simply did not use it. This produced wrong numbers
250
+ for ordinary pictures, not just edge cases.
251
+ - **An empty fractional part invented a digit and a separator.** Fractional digit counts now
252
+ come only from the picture, so `"0."` has none: `$formatNumber(1.5, "0.")` was `"1.5"` and
253
+ is `"2"`, and `$formatNumber(1, "0.")` was `"1."` and is `"1"`.
254
+ - **`#` did not suppress a leading zero.** `$formatNumber(0.25, "#.#")` was `"0.3"` and is
255
+ `".2"`, per F&O 4.7.4's adjustments to the minimum digit counts.
256
+ - **The exponent's upper bound was inclusive.** `$formatNumber(1, "#.e0")` was `"0.1e1"` and
257
+ is `"1.0e0"`.
258
+
259
+ 326 cases across 21 pictures now agree, including negatives, grouping, sub-pictures and
260
+ percent/per-mille. Values at or beyond `1e21` are deliberately excluded: JavaScript switches
261
+ to exponential notation there and the reference formats the resulting text as if it were
262
+ digits, so `$formatNumber(1e21, "#,##0.00")` is `"1e,+21.00"` upstream — a grouping separator
263
+ inside the exponent. (issue [#136](https://github.com/txjmb/jsonata-core/issues/136))
264
+ - `$formatNumber` now reports the same picture-string error as jsonata-js. The reference runs
265
+ every sub-picture check in sequence and lets the *last* failure name the error (F&O 4.7.3);
266
+ ours returned on the first, so a picture failing several checks reported the wrong one —
267
+ `"k"` fails both `D3085` and `D3086` and is `D3086`, `"%%"` was `D3082`, `".."` was `D3081`
268
+ and `"e"` was `D3085`. A picture containing no active character at all was also swallowed
269
+ whole as a prefix rather than treated as the active part, which is what stopped `D3086` from
270
+ ever firing for `"k"` or `"%%"`. 29 pictures now agree where 7 diverged.
271
+ (issue [#135](https://github.com/txjmb/jsonata-core/issues/135))
272
+ - Error *codes* now match jsonata-js where they did not. The differential harness compared
273
+ only "did it raise", so any error satisfied an expected error — `$replace(1)` raising
274
+ "Argument count mismatch" read as a match for the reference's `T0410`. Comparing codes
275
+ surfaced 112 cases across nine shapes, all now fixed: signature arity failures carry `T0410`;
276
+ `$single` reports `D3138`/`D3139`; `$power` with a missing exponent reports `D3061`;
277
+ `$toMillis` on an unparseable timestamp reports `D3110`; `$millis`/`$now` handed arguments
278
+ by a higher-order function report `T0410`; and `$error`/`$assert` treat an undefined argument
279
+ as the signature says they must — `s`/`b` admit *missing*, so `$error(missing.x)` is
280
+ `D3137` ("no message") and `$assert(missing.x)` is `D3141` (a failed assertion), not type
281
+ errors. (issue [#126](https://github.com/txjmb/jsonata-core/issues/126))
282
+ - A builtin bound to a variable now works as a callback: `($f := $uppercase; $map(arr, $f))`
283
+ is `["A", "B"]` rather than `["", ""]`. Binding stores a `JValue::Builtin`, which is not a
284
+ lambda, so `apply_function` fell through to evaluating `$f` as an ordinary variable and the
285
+ callback yielded the function value instead of calling it. Callback arity was resolved from
286
+ the variable's name too, so `$map` handed a one-argument builtin all three of its arguments.
287
+ Both now resolve through the bound builtin, and a host-registered override of the same name
288
+ still suppresses arity truncation. Every builtin was affected, not just the reported
289
+ `$uppercase`; direct invocation (`$f("a")`) always worked, which is why this looked narrow.
290
+ (issue [#126](https://github.com/txjmb/jsonata-core/issues/126))
291
+ - `$base64decode` now accepts what jsonata-js accepts. The reference decodes through Node's
292
+ `Buffer`, which ignores characters outside the base64 alphabet, stops at the first padding
293
+ character, takes the URL-safe alphabet alongside the standard one, and drops an incomplete
294
+ trailing quantum — so `$base64decode("a")` is `""`, `$base64decode("YQ")` is `"a"` and
295
+ `$base64decode("!!!!")` is `""`, where a strict decoder rejects all three.
296
+ - `$parseInteger` no longer raises `D3136` on a value that does not match its picture. The
297
+ reference runs the picture parse without validating the input — its own source marks that
298
+ path `TODO validate input based on the matcher regex` — so the answer is JavaScript's
299
+ `parseInt`: a leading run of digits, or `NaN` when there is none. `$parseInteger("12a", "000")`
300
+ is `12` and `$parseInteger("abc", "000")` is `NaN`. A malformed *picture* still raises
301
+ `D3130`, and the letters/roman/words parsers are untouched because date-time component
302
+ parsing shares them and needs the error. (issue [#126](https://github.com/txjmb/jsonata-core/issues/126))
303
+
304
+ - An explicit null now behaves as a value in object construction and in the `[]` array-keep,
305
+ rather than short-circuiting them: `nul{"a": $}` is `{"a": null}` (it was `null`, and
306
+ disagreed with the dotted `nul.{"a": $}`, which was already right), `nul[]` is `[null]`,
307
+ and `nul[][0]` is `[null]`. Four separate sites each carried a pre-`Undefined` `JValue::Null`
308
+ arm that made the operation a no-op. `emptyarr[]` is now `[]` rather than `undefined` — an
309
+ empty array held in a field is a value, unlike an empty result sequence such as `arr.p` over
310
+ `{"arr": []}`, which stays `undefined`.
311
+ - `[]` and `[true]` are no longer the same thing. Both parsed to `Predicate(Boolean(true))`,
312
+ but jsonata treats them as different operators: `[]` keeps the result an array while
313
+ `[true]` is an ordinary filter that keeps everything and then unwraps a lone result. One of
314
+ the two therefore had to be wrong for every input — `num[]` and `num[true]` were both `[5]`
315
+ where the reference gives `[5]` and `5`. A dedicated `AstNode::KeepArray`/`Stage::KeepArray`
316
+ marker separates them. (issue [#126](https://github.com/txjmb/jsonata-core/issues/126))
317
+ - `#$i` positional binding now unwraps a one-value result, matching every other
318
+ sequence-producing step: `num#$i` is `5` rather than `[5]`, and `deep#$i.$i` is `0` rather
319
+ than `[0]`. The rule holds for every input kind — `arr#$i` and `arrobj#$i` only ever looked
320
+ correct because a multi-element result has no singleton to unwrap, and the filtered form
321
+ `arr#$i[$i=0]` was right for the unrelated reason that a filter predicate already forced
322
+ the unwrap. (issue [#126](https://github.com/txjmb/jsonata-core/issues/126))
323
+ - `*` applied to anything without children is now `undefined` rather than `null`, and its
324
+ result unwraps a lone value the way every other sequence-producing step does. jsonata-js
325
+ guards the wildcard with `typeof input === 'object' && input !== null`, so `num.*`,
326
+ `str.*`, `true.*` and the rest all map over nothing; only the `null` case had been fixed
327
+ here, leaving the arm inconsistent with the catch-all beside it, and the two collapse into
328
+ one now that they give the same answer. Separately, `*` was missing from the list of steps
329
+ that produce a query-result sequence — the list `**` and filter predicates are already on —
330
+ so a one-value result stayed wrapped: `deep.*` over `{"a": {"b": 1}}` was `[{"b": 1}]` and
331
+ is now `{"b": 1}`, which also lets `deep.*.*` reach the inner value instead of an array.
332
+ (issue [#126](https://github.com/txjmb/jsonata-core/issues/126))
333
+ - Six builtins — `$base64encode`, `$base64decode`, `$toMillis`, `$fromMillis`,
334
+ `$formatInteger` and `$parseInteger` — now validate their arguments. They had no entry in
335
+ the builtin signature table, and a missing entry is not a weaker check but no check at
336
+ all: validation returns early for a name it does not know, so these six fell back to
337
+ hand-rolled arity and type guards. An explicit null returned `null` where jsonata-js
338
+ raises `T0410` (`$base64encode(null)`), the `-` context marker never fired
339
+ (`str.$base64encode()` was `null`, not `"YQ=="`), `$formatInteger`'s context form was
340
+ rejected as an arity error, and `$base64encode`/`$base64decode` raised a type error on a
341
+ missing argument instead of propagating `undefined`. The table had drifted to a strict
342
+ *subset* of jsonata-js's own — 55 of its 63 entries, every one byte-identical, with eight
343
+ simply absent — which is why nothing caught it; a new test compares the two tables in
344
+ both directions against a generated fixture, so a submodule bump that adds or changes a
345
+ signature now fails CI. The two entries still absent are deliberate and named in that
346
+ test: `$clone` is not implemented here at all, and `$eval` needs the evaluator.
347
+ (issue [#126](https://github.com/txjmb/jsonata-core/issues/126))
348
+ - `$sum`, `$max`, `$min` and `$average` over a path of the form `array.field` no longer
349
+ silently skip non-numeric values. The fused aggregate fast path
350
+ (`Evaluator::try_fused_aggregate`) reimplemented aggregate semantics rather than
351
+ delegating to them, and treated a present non-numeric field the same as an absent one.
352
+ With `{"orders": [{"p": 1}, {"p": "free"}]}`, `$sum(orders.p)` returned `1` instead of
353
+ raising `T0412` — a plausible but wrong number rather than an error. The same path also
354
+ returned `0`/`null` for an empty sequence where jsonata-js returns `undefined`. The fast
355
+ path now declines when its assumptions do not hold, so the canonical aggregate produces
356
+ both the error and the empty-sequence semantics. (issue [#97](https://github.com/txjmb/jsonata-core/issues/97))
357
+ - Builtin argument handling now matches jsonata-js for a missing argument in a *required*
358
+ slot. The reference validates a call and then hands the arguments to the function body
359
+ unchanged, so an undefined that the signature admitted reaches a JavaScript expression
360
+ and JavaScript's coercion supplies the answer: `$substring("abcdef", missing.x)` is
361
+ `"abcdef"` (and `$substring("abcdef", missing.x, 2)` is `""`, because the undefined start
362
+ makes the end `NaN`), `$pad("a", missing.x)` is `"a"`, and `$substringBefore` and
363
+ `$lookup` stringify the missing argument to the literal `"undefined"` rather than
364
+ treating it as absent. `$trim(missing.x)` propagates undefined instead of raising.
365
+ - `$substring`, `$substringBefore` and `$substringAfter` given a single missing argument
366
+ now raise `T0411` on both engines. The compiled path applied its undefined-propagation
367
+ shortcut before signature validation; jsonata-js validates first, and for these three
368
+ the lone undefined argument binds to parameter 2 while parameter 1 comes from the
369
+ context. The tree-walker never had the shortcut, so the two engines disagreed.
370
+ - `$spread` and `$each` now follow jsonata-js's sequence rules: `$spread({"k": 1})` is the
371
+ object rather than `[{"k": 1}]`, and `$spread([])`, `$spread({})` and `$each({}, fn)` are
372
+ `undefined` rather than empty containers. `$spread`'s *array* branch is deliberately
373
+ exempt — the reference folds it with `concat`, which drops the sequence flag — so
374
+ `$spread([{"k": 1}])` stays wrapped. `$each` also no longer drops explicit nulls from its
375
+ results.
376
+ - `$sift(obj)` now raises instead of returning `undefined`. The one-argument form is
377
+ `$sift(function)`, with the object taken from the context.
378
+ (issue [#104](https://github.com/txjmb/jsonata-core/issues/104))
379
+ - `$trim`, `$merge`, `$reverse`, `$distinct`, `$join` and `$keys` given a missing argument
380
+ now yield `undefined` rather than `null`, matching jsonata-js. This was visible through
381
+ object construction, which drops an undefined-valued key but keeps a null-valued one:
382
+ `{"k": $trim(missing)}` was `{"k": null}` and is now `{}`. For the first five, the two
383
+ evaluation routes previously disagreed — the compiled/VM path returned `null` while the
384
+ tree-walker returned `undefined` — and the shared dispatcher makes them agree. `$keys`
385
+ was wrong on both routes; the tree-walker arm had no `Undefined` case at all, and the new
386
+ shared dispatcher's corpus is what exposed it.
387
+ (issue [#107](https://github.com/txjmb/jsonata-core/issues/107))
388
+
389
+ ### Deliberately not changed
390
+ - `$base64encode`/`$base64decode` keep treating their payload as UTF-8, because jsonata's own
391
+ documentation asks for both latin1 *and* UTF-8 and its implementation matches only the first.
392
+ `$base64encode` is documented as latin1 — "all characters in the string are in the 0x00 to
393
+ 0xFF range... Unicode characters outside of that range are not supported" — while
394
+ `$base64decode` is documented as "using a UTF-8 Unicode codepage", which its implementation
395
+ does not do. No reading of the reference is self-consistent, and UTF-8 on both sides is the
396
+ half that matches a documented contract exactly while also round-tripping. Above `0xFF`
397
+ nothing is defined at all: `window.btoa` throws `InvalidCharacterError` where Node truncates
398
+ each UTF-16 code unit to a byte, so browser and Node disagree. The cost is real but narrow —
399
+ for `0x80`–`0xFF`, encode has an environment-independent documented answer we do not give
400
+ (`$base64encode("héllo")` is `"aOlsbG8="` upstream, `"aMOpbGxv"` here); matching it would
401
+ require latin1 decode too, which would then contradict the decode docs. A unit test pins our
402
+ choice, with the full reasoning, so it cannot flip unnoticed.
403
+ (issue [#126](https://github.com/txjmb/jsonata-core/issues/126))
404
+
405
+ ### Security
406
+
407
+ ## [2.2.7] - 2026-07-21
408
+
409
+ ### Added
410
+
411
+ ### Changed
412
+ - Dependabot no longer auto-bumps the `tests/jsonata-js` reference submodule (removed the
413
+ `gitsubmodule` ecosystem entry). Reference-suite updates are handled by the
414
+ `sync-jsonata.yml` workflow, which runs the conformance suite against each new jsonata-js
415
+ release and opens a tracking issue (or a clean PR) — avoiding context-free failing bump
416
+ PRs like #82.
417
+
418
+ ### Deprecated
419
+
420
+ ### Removed
421
+
422
+ ### Fixed
423
+ - Ensures compliance with the **jsonata-js 2.2.2** reference test suite (reference submodule
424
+ bumped to `6c7e95f`); the full reference suite — 1686 cases — passes. Three behavior
425
+ changes were required to match jsonata-js 2.2.2:
426
+ - `$contains(str, token)` now returns `undefined` when either argument is undefined,
427
+ instead of raising a type error (jsonata-js #809).
428
+ - `$each(obj, fn)` now returns `undefined` when its first argument is undefined, instead
429
+ of raising `each() first argument must be an object`.
430
+ - An object constructor (group-by) applied to an empty or undefined sequence now yields
431
+ an empty object `{}` instead of `undefined` (jsonata-js #817, "correctly handle empty
432
+ joins"); `null` input still returns `null` and non-empty grouping is unchanged.
433
+
434
+ ### Security
435
+
436
+ ## [2.2.6] - 2026-07-20
437
+
438
+ ### Added
439
+ - Host-callable custom functions (Rust core): `Evaluator::register_fn` and
440
+ `Evaluator::register_fn_override` let a host register native functions callable
441
+ from an expression as `$name(...)` — the equivalent of jsonata-js's
442
+ `registerFunction`. Functions are plain closures
443
+ (`Fn(&[JValue]) -> Result<JValue, EvaluatorError>`) and resolve after the
444
+ expression's own bindings/lambdas and before built-ins. `register_fn` rejects
445
+ collisions with built-ins; `register_fn_override` allows deliberately replacing
446
+ the impure built-ins (`$now`, `$millis`, `$random`, `$eval`) for determinism
447
+ injection or sandboxing. Evaluation stays synchronous. See
448
+ `examples/host_functions.rs` and the Rust crate docs.
449
+ - Host-callable custom functions (Python binding): `JsonataExpression.register(name,
450
+ func)` and `.register_override(name, func)` expose the above to Python. The callable
451
+ receives already-evaluated positional arguments and must return a JSON-compatible
452
+ value synchronously; an `async def` (coroutine) is rejected at call time with
453
+ guidance to await I/O outside jsonata and pass results via `bindings`. Collision and
454
+ compilable-builtin-override rules are validated at `register()` time.
455
+ - Host-callable custom functions (C ABI): `jsonata_register_fn(expr, name, fn, user_data)`
456
+ and `jsonata_register_fn_override(...)` expose the feature to C and any language with C
457
+ interop. The callback receives its arguments as a JSON array string and returns a JSON
458
+ result string (jsonata copies it; the host retains ownership), or NULL to signal an error.
459
+ See `bindings/c/jsonata.h`, `bindings/c/README.md`, and `bindings/c/examples/smoke.c`.
460
+
461
+ ### Changed
462
+
463
+ ### Deprecated
464
+
465
+ ### Removed
466
+
467
+ ### Fixed
468
+ - Path expressions no longer drop explicit `null` values from query-result sequences.
469
+ `evaluate_path`'s array-mapping fast path predates the null/undefined migration in #32
470
+ and skipped both, so `arr.p` over `[{"p": 1}, {"p": null}]` returned `1` instead of
471
+ `[1, null]`. Only an *absent* field is undefined and drops out; a present `null` is a
472
+ value and stays. Fixed for both the JSON and Python-dict (lazy view) routes. This
473
+ corrects everything downstream of such a sequence — `$count`, array construction,
474
+ comparison and arithmetic operands, and the fused aggregates, which now raise `T0412`
475
+ on a null element rather than silently summing around it.
476
+ ([#98](https://github.com/txjmb/jsonata-core/issues/98), root cause 1)
477
+ - Filter predicates now unwrap a single-element result, so `arr[p = 1]` is `{"p": 1}` rather
478
+ than `[{"p": 1}]`. The tree-walker decided this from `step.stages` alone, but `arr[p = 1]`
479
+ parses its predicate as a `Predicate` step *node* with empty stages, so no filter written
480
+ that way was ever recognised as an array operation. Numeric-literal predicates are excluded:
481
+ those are index access and already return the selected element, so counting them would
482
+ unwrap twice and turn `a[0]` over `[[5]]` into `5`.
483
+ ([#98](https://github.com/txjmb/jsonata-core/issues/98), root cause 2)
484
+ - Numeric filter predicates now select by position instead of being treated as truthy. In
485
+ JSONata `arr[p]` keeps an element only when `p` equals that element's own index; negative
486
+ values count from the end and fractional values floor, and an array of numbers is a set of
487
+ such indices. The tree-walker previously evaluated the predicate against the whole array
488
+ and treated a numeric result as a multi-index selector, so `arr[p]` over
489
+ `[{"p": 1}, {"p": 2}]` returned both elements instead of nothing. Fixed for standalone
490
+ predicates; filters in *stage* position (`a.b[-1]`, which maps the index over each
491
+ extracted sub-array) keep their existing semantics.
492
+ ([#98](https://github.com/txjmb/jsonata-core/issues/98), root cause 3)
493
+ - The compiled path and bytecode VM now apply the same index rule. `CompiledStep` records
494
+ whether its filter came from a standalone `Predicate` step or a `Stage::Filter`, a
495
+ distinction the compiler previously discarded on the stated assumption that "both
496
+ encodings have identical runtime semantics" -- true for boolean predicates, false for
497
+ numeric ones. ([#98](https://github.com/txjmb/jsonata-core/issues/98), root cause 3)
498
+ - A predicate applied to a non-array value now treats it as the singleton sequence it is:
499
+ index 0, length 1. `arr[p]` over `{"p": 1}` is undefined (1 does not match index 0) and
500
+ `arr[-1]` wraps to the value itself. A string predicate on an object is no longer computed
501
+ property access -- `o["a"]` keeps the object because a non-empty string is truthy, matching
502
+ jsonata-js, rather than looking up the key.
503
+ ([#98](https://github.com/txjmb/jsonata-core/issues/98), root cause 4)
504
+ - A non-empty Python `dict` is no longer falsy on the bytecode VM and compiled paths.
505
+ Dicts cross the boundary as a lazy view rather than a materialised object, and
506
+ `compiled_is_truthy` had no arm for that variant, so it fell through to its catch-all and
507
+ returned `false` for every one. This affected any truthiness context on the compiled path
508
+ -- `o ? a : b`, `and`/`or`, `$boolean`, `$not`, filter predicates -- and only when data was
509
+ passed as a dict, so the same expression over an equivalent JSON string was correct. The
510
+ tree-walker was unaffected. ([#98](https://github.com/txjmb/jsonata-core/issues/98))
511
+ - Ordered comparisons (`<`, `<=`, `>`, `>=`) against an undefined operand now return undefined
512
+ instead of raising `T2010`, and an explicit `null` operand now raises `T2010` instead of
513
+ returning null. `ordered_compare` predated the null/undefined split and matched only on
514
+ `JValue::Null`, so a real `Undefined` reached its catch-all. Rewritten to jsonata-js's rule:
515
+ only numbers, strings and undefined are comparable; an undefined operand yields undefined;
516
+ otherwise a type mismatch is `T2009`.
517
+ - An unbound variable (`$x`) now evaluates to undefined rather than null, so `3 > $x` is
518
+ undefined, `{"a": $x}` drops the key, and `$not($x)` is undefined -- all matching jsonata-js.
519
+ The surrounding comment already described these as the intended results; only the value was
520
+ wrong. ([#98](https://github.com/txjmb/jsonata-core/issues/98), root cause 5)
521
+ - Explicit nulls now survive a stage filter (`arr.p[-1]`, `arr.p[0]`, `arr.p[]`). The
522
+ tuple/stage branch of `evaluate_path` mapped an absent field to `JValue::Null` and then
523
+ skipped every null, dropping present nulls alongside genuinely missing fields -- the same
524
+ pre-migration pattern already fixed in the no-stages fast path, in three more places
525
+ (the object arm, the tuple arm and the lazy-dict arm).
526
+ ([#98](https://github.com/txjmb/jsonata-core/issues/98))
527
+ - `arr.p[-1]` now takes the last element of each extracted group on the bytecode VM, matching
528
+ the tree-walker. Numeric-literal predicates are index access and are deliberately left to
529
+ the tree-walker, but the guard tested only for `AstNode::Number` -- `[-1]` parses as a
530
+ *negation* of a literal, slipped through, and compiled to a plain truthy constant that kept
531
+ every element. ([#98](https://github.com/txjmb/jsonata-core/issues/98))
532
+ - Arithmetic on an explicit `null` now raises instead of silently producing null, including
533
+ when the null arrives at runtime rather than as a literal -- `$map([1, null], function($v)
534
+ { $v * 2 })` raises `T2001` where it previously returned `[2, null]`. Only *undefined*
535
+ propagates. Error codes now match jsonata-js: a bad left operand is `T2001` and a bad right
536
+ operand is `T2002` (previously `T2002` for both), and each defined operand is type-checked
537
+ before undefined propagation, so `false + $x` raises rather than returning undefined. The
538
+ five tree-walker operators now delegate to the same shared implementation as the compiled
539
+ path and VM instead of each carrying its own copy of the null handling.
540
+ ([#98](https://github.com/txjmb/jsonata-core/issues/98))
541
+ - Ordered comparisons inside filters and sort comparators now reject uncomparable operands.
542
+ `compiled_ordered_cmp` was the un-migrated twin of `Evaluator::ordered_compare`: it still
543
+ conflated `JValue::Null` with `JValue::Undefined`, so `arr[p > 1]` over
544
+ `[{"p": 1}, {"p": null}]` silently returned undefined where jsonata-js raises `T2010`.
545
+ Rewritten to the same rule -- only numbers, strings and undefined are comparable; an
546
+ undefined operand yields undefined; a type mismatch is `T2009`.
547
+ - `$sort` comparators of the form `function($l, $r) { $l.f > $r.f }` no longer sort inputs
548
+ that jsonata-js rejects. The specialized Schwartzian-transform fast path collapsed every
549
+ non-numeric, non-string key into "missing" and treated mixed types as "keep original
550
+ order"; it now declines those inputs so the general comparator raises `T2010`/`T2009`.
551
+ Absent keys are still undefined and still sort last on the fast path.
552
+ ([#102](https://github.com/txjmb/jsonata-core/issues/102), cluster A)
553
+ - `$map` and `$filter` now return sequences rather than arrays: a single result unwraps to
554
+ that result and an empty result is undefined, so `$map(arr, function($v){$v.p})` over
555
+ `[{"p": "free"}]` is `"free"` rather than `["free"]`, and over `[]` is undefined rather
556
+ than `[]`. `$map` also accepts a non-array argument as the singleton sequence containing
557
+ it, which `$filter` already did.
558
+ - Field access on a lambda parameter (`$v.p`, `$l.rating`) now yields undefined for a missing
559
+ field instead of null. The `$var.field` fast path used by sort and higher-order-function
560
+ bodies predates the null/undefined split, which is why `$map` produced `[1, null]` where
561
+ jsonata-js drops the undefined, and why `$filter` raised `T2010` comparing what was really
562
+ a missing field. ([#102](https://github.com/txjmb/jsonata-core/issues/102), cluster B)
563
+ - `!=` against an undefined operand is now `false`, matching `=`. jsonata-js returns false
564
+ for both when either side is undefined -- `!=` is not the negation of `=` there -- so
565
+ `arr[p != null]` no longer keeps elements whose `p` is missing.
566
+ - Object construction as a path step now follows sequence semantics: `arr.{"k": p}` over a
567
+ single element is the object rather than a one-element array, and over a non-array value it
568
+ builds from that value instead of from the root document (previously `{}`).
569
+ ([#102](https://github.com/txjmb/jsonata-core/issues/102), cluster C)
570
+ - `&` now stringifies an explicit `null` as `"null"`, matching `$string(null)`, and treats
571
+ only an *undefined* operand as the empty string. `null & "x"` was `"x"` and is now
572
+ `"nullx"`; `missing.x & "x"` is still `"x"`.
573
+ - `in` is membership again, not array filtering. An array on the left made `evaluate_binary_op`
574
+ treat the expression as `array[predicate]`, so `arr in 1` evaluated as `arr[1]` and returned
575
+ an element. It now follows jsonata-js: an undefined operand on either side gives `false`, a
576
+ non-array right side is wrapped, and membership is decided with `===` -- primitives by value,
577
+ composites by identity. `obj in [obj]` is true, `obj in [{"k": 1}]` is false, and an object
578
+ on the right is no longer treated as key-containment (`"k" in obj` is `false`).
579
+ - Division and modulo by zero no longer raise. jsonata-js checks operands, never results:
580
+ `1/0` is `Infinity` and `0/0` is `NaN`, and the `D1001` appears when such a value is used as
581
+ an operand (`1/(10e300 * 10e100)`) or serialised inside a composite
582
+ (`$string({"inf": 1/0})`). The multiply overflow check moved from the result to the operands
583
+ to match. JSON cannot spell Infinity, so the JSON-returning APIs give `null` for it, exactly
584
+ as JavaScript's `JSON.stringify` does.
585
+ - Unary negation of an explicit `null` now raises `D1002` instead of returning null; only
586
+ *undefined* propagates.
587
+
588
+ ### Security
589
+
590
+ ## [2.2.5] - 2026-07-14
591
+
592
+ ### Added
593
+ - C API (`capi` cargo feature): use the engine from C, C++, or any language with C interop.
594
+ Eight functions, JSON text in/out (`jsonata_compile`, `jsonata_evaluate`, `jsonata_bind_var`,
595
+ `jsonata_free_expr`, `jsonata_free_string`, `jsonata_last_error_message`,
596
+ `jsonata_last_error_code`, `jsonata_version`), thread-local error slot, and engine panics
597
+ caught at the boundary instead of aborting the host process. Ships with a hand-written
598
+ header (`bindings/c/jsonata.h`), build/link/CMake documentation (`bindings/c/README.md`),
599
+ and a CI-gated smoke test compiled as both C and C++. Build with
600
+ `cargo build --release --features capi`.
601
+
602
+ ### Fixed
603
+ - Performance regression in v2.2.4 on small/fast expressions (issue #74): the
604
+ `JSONATAPY_FORCE_TREE_WALKER` test toggle read the environment variable on every
605
+ evaluation (both `evaluate()` and `evaluate_json()`), costing ~100-200ns per call —
606
+ 10-30% of a sub-microsecond expression. The toggle is now a process-wide atomic seeded
607
+ from the environment once at import (whole-process forcing works unchanged) and flippable
608
+ via a private test hook. Small-expression benchmarks recover 5-16%; the remaining few
609
+ percent vs v2.2.3 on tiny payloads is the documented cost of lazy conversion (which makes
610
+ realistic workloads up to 48% faster, see 2.2.4 notes).
611
+
612
+ ### Changed
613
+ - Benchmark tooling: PR benchmark comments and release regression issues now state their
614
+ comparison baseline explicitly (which commit/release, recorded when, on which runner),
615
+ and the vs-jsonata-js comparison is labeled as such.
616
+
617
+ ## [2.2.4] - 2026-07-13
618
+
619
+ ### Added
620
+ - `JSONATAPY_FORCE_TREE_WALKER=1` environment variable (testing/debugging): forces every
621
+ evaluation through the tree-walking evaluator, bypassing the default lazy/VM-preferred path.
622
+
623
+ ### Changed
624
+ - `evaluate(dict)` now converts Python data lazily by default. Previously every call eagerly
625
+ converted the entire input `dict` (and nested structures) to the internal value tree before
626
+ evaluation began; now only the fields an expression actually touches are converted, and
627
+ untouched input subtrees pass straight through to the output unchanged. Measured on the dev
628
+ machine (min-of-5, vs. the prior eager `evaluate(dict)`):
629
+ - `products.price` (100 objects): 34.9µs → 11.3µs (3.1x)
630
+ - Filter by category (100 products, 9 fields each): 129.3µs → 23.4µs (5.5x)
631
+ - `$sum(products[inStock].price)`: 116.4µs → 17.1µs (6.8x)
632
+ - Complex dense transformation: 160.9µs → 79µs (2.0x)
633
+
634
+ Every measured row now beats jsonata-js on identical data and machine (14.6/93.7/67.6/415.1µs
635
+ respectively). See `benchmarks/python/lazy_check.py` for the reproducible gate.
636
+ - **Behavior change:** results containing an unmodified input subtree now reference the
637
+ caller's *original* Python `dict`/`list` objects (result aliasing, matching jsonata-js),
638
+ rather than a fresh copy. Mutating such a result mutates the corresponding input — copy
639
+ explicitly (e.g. `copy.deepcopy`) first if you plan to mutate. Passed-through values also keep
640
+ their exact Python type (an `int` field the expression never reads stays an `int`); fields the
641
+ expression does touch still round-trip through the engine's number representation (whole
642
+ values come back as Python `int`), as before.
643
+ - **Behavior change:** an unconvertible input value (e.g. a `set`) now raises `TypeError` only
644
+ when the expression actually touches it, instead of eagerly for the whole input at the start
645
+ of `evaluate()`. Any Python exception raised while lazily reading a field (e.g. `OverflowError`
646
+ on an integer too large to represent) is likewise normalized to `TypeError` at this boundary,
647
+ rather than propagating as its original exception type.
648
+
649
+ ### Deprecated
650
+
651
+ ### Removed
652
+
653
+ ### Fixed
654
+
655
+ ### Security
656
+
657
+ ## [2.2.3] - 2026-07-12
658
+
659
+ ### Added
660
+ - `evaluate_json_or_none()`'s `json_str` parameter now accepts `None` (in addition to a JSON
661
+ string), binding the top-level context (`$`) to a true JSONata `Undefined` rather than an
662
+ explicit `null`. (#68)
663
+ - `docs/cli.md`: a full command-line reference for both `jsonata` (Rust) and `jsonatapy`
664
+ (Python), covering flags, input resolution, output/exit-code semantics, and the MCP
665
+ subcommand — previously undocumented outside internal spec/plan files. (#68)
666
+
667
+ ### Changed
668
+ - Release CLI binary archives are now named `jsonata-v<version>-<target>.tar.gz`/`.zip`
669
+ (previously `jsonata-<target>.tar.gz`/`.zip` with no version embedded).
670
+
671
+ ### Deprecated
672
+
673
+ ### Removed
674
+
675
+ ### Fixed
676
+ - The Python CLI's `-n`/`--null-input` now binds `$` to a true `Undefined`, matching the Rust
677
+ CLI exactly (`jsonatapy -n '$'` now prints nothing, as `jsonata -n '$'` already did). It
678
+ previously passed an explicit JSON `null` context instead, observable only for expressions
679
+ referencing `$` directly. (#68)
680
+ - Release workflow: `publish-pypi` and `publish-crates` now require `build-cli-binaries` to
681
+ succeed first. Previously the two registry publishes were independent of the CLI binary
682
+ build, so a CLI build failure (untested in a real release prior to this) would have shipped
683
+ a version to PyPI/crates.io with no CLI binaries attached and no way to reuse that version
684
+ number.
685
+
686
+ ### Security
687
+
688
+ ## [2.2.2] - 2026-07-09
689
+
690
+ ### Added
691
+ - Documented `JsonataData`, `evaluate_with_data`, and `evaluate_data_to_json` in `docs/api.md`
692
+ (previously absent from the API reference entirely, despite being the "3-15x faster"
693
+ pre-converted-data path highlighted in the README's Performance section). Added a cross-link
694
+ from `docs/rust-crate.md` to the full auto-generated API reference on docs.rs. (#65)
695
+
696
+ ### Changed
697
+ - Updated Rust dependencies to the latest versions compatible with existing (unpinned) semver
698
+ requirements: `simd-json` 0.17.0 → 0.17.2 plus 6 transitive patch bumps. No security advisories
699
+ found before or after (`cargo deny check`). (#65)
700
+
701
+ ### Deprecated
702
+
703
+ ### Removed
704
+
705
+ ### Fixed
706
+ - SIMD-accelerated JSON parsing (`simd-json` feature, on by default) was consistently *slower*
707
+ than the plain `serde_json` fallback for most payload sizes (up to 29% slower at 180KB), the
708
+ opposite of its intent — caused by allocating fresh internal scratch buffers on every single
709
+ parse call. Fixed by reusing a thread-local scratch buffer across calls; SIMD parsing now beats
710
+ `serde_json` consistently (up to +22% faster) instead of losing at 3 of 4 tested sizes.
711
+ Also corrected the README's "(optional feature)" wording for SIMD, which implied opt-in when
712
+ it's actually enabled by default, including in published wheels. (#65)
713
+
714
+ ### Security
715
+
716
+ ## [2.2.1] - 2026-07-08
717
+
718
+ > Same code as [2.1.7](#217---2026-07-08) below, renumbered. This project's release versions
719
+ > track the jsonata-js major/minor version they target (patch numbers are independent — see
720
+ > README). `2.1.7` incorrectly continued the old `2.1.x` patch series even though this release's
721
+ > guardrails feature and signature-engine fixes target jsonata-js `2.2.0`/`2.2.1`; `2.1.7` is
722
+ > superseded immediately by this release and should not be used.
723
+
724
+ ### Added
725
+ - Guardrails: `timeout` (ms, error code `D1012`), `max_stack_depth` (error code `D1011`), and
726
+ `max_sequence_length` (error code `D2015`) keyword arguments on `compile()` and every
727
+ `evaluate*()` call, enforced consistently across all three execution engines (tree-walker,
728
+ compiled-expression fast path, bytecode VM). All default to `None` (unlimited) — no behavior
729
+ change unless configured. See [Guardrails](docs/api.md#guardrails). (jsonata-js 2.2.1 Phase 2, #56)
730
+ - Documented the guardrails API in `docs/api.md`, `docs/usage.md`, and `docs/error-handling.md`
731
+ (previously shipped with accurate Python docstrings but no user-facing docs), and corrected
732
+ `docs/migration-from-js.md`'s stale claim that Python had no built-in timeout support.
733
+
734
+ ### Fixed
735
+ - A deeply-nested expression (arithmetic chains, parenthesized/grouped expressions) no longer
736
+ crashes the whole process (previously a native stack overflow) — now raises a graceful `U1002`
737
+ error instead, via a depth guard in the parser and a second, defense-in-depth guard in the
738
+ post-parse AST pass.
739
+ - `Instr::MakeArray`/`MakeObject`/`BlockEnd`'s bytecode operands (and `CallBuiltin`'s argument
740
+ count, and internal constant-pool bookkeeping) no longer silently produce wrong, truncated
741
+ results for oversized literals/blocks/calls (e.g. array literals with more than 65,535
742
+ elements) — such cases now fall back to the always-correct tree-walker instead.
743
+ - `ast_transform.rs`'s depth-guard error messages no longer imply `%`/`@`/`#` ancestor-operator
744
+ usage (e.g. "...while resolving ancestor/path metadata") when the guard fires for any
745
+ sufficiently-nested expression, including plain arithmetic.
746
+ - Release workflow now fails loudly on a fresh dispatch when the target version tag already
747
+ exists at a different commit, instead of silently reusing the wrong commit (#53).
748
+
749
+ ## [2.1.7] - 2026-07-08
750
+
751
+ **Superseded by [2.2.1](#221---2026-07-08) above, published the same day.** This version was
752
+ numbered following a simple patch-increment from `2.1.6` rather than this project's actual
753
+ versioning policy (track jsonata-js's major/minor). It is fully functional and was not yanked —
754
+ package registries don't allow deleting a published version — but `2.2.1` is the version that
755
+ should be used going forward.
756
+
757
+ ### Added
758
+ - Guardrails: `timeout` (ms, error code `D1012`), `max_stack_depth` (error code `D1011`), and
759
+ `max_sequence_length` (error code `D2015`) keyword arguments on `compile()` and every
760
+ `evaluate*()` call, enforced consistently across all three execution engines (tree-walker,
761
+ compiled-expression fast path, bytecode VM). All default to `None` (unlimited) — no behavior
762
+ change unless configured. See [Guardrails](docs/api.md#guardrails). (jsonata-js 2.2.1 Phase 2, #56)
763
+ - Documented the guardrails API in `docs/api.md`, `docs/usage.md`, and `docs/error-handling.md`
764
+ (previously shipped with accurate Python docstrings but no user-facing docs), and corrected
765
+ `docs/migration-from-js.md`'s stale claim that Python had no built-in timeout support.
766
+
767
+ ### Fixed
768
+ - A deeply-nested expression (arithmetic chains, parenthesized/grouped expressions) no longer
769
+ crashes the whole process (previously a native stack overflow) — now raises a graceful `U1002`
770
+ error instead, via a depth guard in the parser and a second, defense-in-depth guard in the
771
+ post-parse AST pass.
772
+ - `Instr::MakeArray`/`MakeObject`/`BlockEnd`'s bytecode operands (and `CallBuiltin`'s argument
773
+ count, and internal constant-pool bookkeeping) no longer silently produce wrong, truncated
774
+ results for oversized literals/blocks/calls (e.g. array literals with more than 65,535
775
+ elements) — such cases now fall back to the always-correct tree-walker instead.
776
+ - `ast_transform.rs`'s depth-guard error messages no longer imply `%`/`@`/`#` ancestor-operator
777
+ usage (e.g. "...while resolving ancestor/path metadata") when the guard fires for any
778
+ sufficiently-nested expression, including plain arithmetic.
779
+ - Release workflow now fails loudly on a fresh dispatch when the target version tag already
780
+ exists at a different commit, instead of silently reusing the wrong commit (#53).
781
+
782
+ ## [2.1.6] - 2026-07-07
783
+
784
+ ### Added
785
+ - `%` (parent-reference) and `@`/`#` (focus/index binding) operators.
786
+ - jsonata-js 2.2.1 Phase 1: signature engine rewrite adding `+`/`-` arity support (#36).
787
+ - Versioned documentation via `mike`, fixing a gh-pages deploy race (#39).
788
+
789
+ ### Fixed
790
+ - Reference test-suite coverage gaps: loader fix, datetime picture-strings,
791
+ `formatInteger`/`parseInteger` (Phases 0-2), and array-constructor/`distinct` stragglers
792
+ (Phase 5, #44).
793
+ - `release.yml` never actually built macOS wheels, due to a broken `actions/setup-python`
794
+ invocation (#41).
795
+ - Several benchmark accuracy/fairness corrections (await `jsonata-js` calls properly, substantiate
796
+ pre-converted-data speedup claims, use clean CI-sourced numbers, use jsonata-python's `Context`
797
+ for fair repeated-eval timing) (#45, #46, #49, #50).
798
+
799
+ ### Changed
800
+ - macOS temporarily dropped from the main release matrix pending a self-hosted runner fix (#45,
801
+ #47) — later restored via a self-hosted Mac Mini runner.
802
+
803
+ ## [2.1.5] - 2026-07-04
804
+
805
+ ### Fixed
806
+ - Native stack overflow on deep recursion — replaced with a graceful, coded error (fixes #34).
807
+ - Lambda IDs are now generated from a monotonic counter instead of an AST pointer address, fixing
808
+ a ~0.5%-frequency wrong-closure bug from id aliasing across recursive/repeated evaluation
809
+ (fixes #35).
810
+ - Tree-walker missing-path/field access now correctly produces `Undefined` instead of `Null` in
811
+ the ~20 sites that predated the `Null`/`Undefined` distinction (fixes #32).
812
+ - CI repairs following the default-branch rename to `main`; patched `RUSTSEC-2026-0097`.
813
+
814
+ ### Changed
815
+ - Bumped `pyo3` to 0.29 and `rand` to 0.10.
816
+
817
+ ## [2.1.4] - 2026-03-22
818
+
819
+ ### Added
820
+ - `cargo publish` step in the release workflow.
821
+
822
+ ### Fixed
823
+ - Release workflow is now idempotent for version-bump and tag-creation steps.
824
+ - Corrected an incorrect expected value in the `substring` test suite.
825
+
826
+ ## [2.1.3] - 2026-03-22
827
+
828
+ > Versions 2.1.1 and 2.1.2 were bumped internally but never published as standalone tagged
829
+ > releases — their changes are folded into this entry, the next version actually released.
830
+
831
+ ### Added
832
+ - Bytecode VM (`compiler.rs` + `vm.rs`, "Phase 4") restored and wired into the Python execution
833
+ path, with Criterion benchmarks comparing it against the tree-walker.
834
+ - `pyo3` made an optional dependency; Rust crate renamed to `jsonata-core` and published to
835
+ crates.io independently of the `jsonatapy` PyPI package.
836
+
837
+ ### Fixed
838
+ - PyO3 0.28 compatibility (`PyObject` → `Py<PyAny>`).
839
+ - Clippy deprecations and `cargo-deny` license-check failures.
840
+ - Upgraded Pillow (dev/docs dependency) to 12.1.1 for a CVE fix.
841
+
842
+ ### Changed
843
+ - Multiple benchmark documentation and accuracy corrections.
844
+
845
+ ## [2.1.0] - 2026-02-08
846
+
847
+ ### Added
848
+ - Initial public release: Rust-based JSONata implementation targeting jsonata-js v2.1.0 semantics.
849
+ - Full jsonata-js v2.1.0 reference test-suite compatibility.
850
+ - Python bindings (PyO3), published as `jsonatapy` on PyPI.
851
+ - Rust core, published as `jsonata-core` on crates.io.
852
+
853
+ ---
854
+
855
+ ## Reference Implementation Tracking
856
+
857
+ This project tracks the [jsonata-js](https://github.com/jsonata-js/jsonata) reference implementation.
858
+
859
+ **Current status:** Full test-suite compatibility with jsonata-js v2.1.0 (+3 commits). jsonata-js
860
+ v2.2.1 compatibility work is in progress — Phase 1 (signature engine, `+`/`-` arity support) and
861
+ Phase 2 (resource guardrails) are done; see
862
+ `docs/superpowers/specs/2026-07-04-jsonata-2.2.1-design.md` for the authoritative, up-to-date
863
+ status of this effort.
864
+
865
+ ### Version History
866
+ - Target tracking v2.1.0 - Project initialization (2025-01-17)