fix(codec): a whole-valued float still decodes as an integer (#32)

The signedness/range check in #31 went one step too far: it rejected 3.0 for an
`int`, not just 3.7. That broke four long-standing test_basic_module_cpp cases
(`addInts(3.0, 4.0)`, `echoInt(42.0)`, `isPositive(5.0)`, `twoArgs(hi, 3.0)`)
which pass a whole-valued double where the contract declares an integer.

They are right and the check was wrong. JSON does not distinguish 3 from 3.0,
and this codec already says so in the other direction — Codec<double> accepts an
integral number because "2 and 2.0 are the same value to JSON, and every encoder
that sees a whole double may emit either". The two directions have to agree.

It also matters in practice rather than in principle: logoscore's CLI types its
arguments by parsing, so `logoscore call m addInts 3.0 4.0` produces JSON floats.
Refusing them rejects a caller over a spelling of the same number.

So a float decodes as an integer when it has no fractional part and fits;
3.7 is still refused, which is what the original change was actually for. Bounds
are strict on the upper end for the same reason as the QJsonValue guard:
double(int64max) rounds UP to 2^63, so `<=` would admit a value the cast cannot
represent.

verified: test-modules 176/176 with the four cases green again, and the
conformance matrix unchanged at 170 pass / 2 xfail — hostile/int/fractional
still expects dispatch_failed and gets it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Lipicar
2026-07-29 13:52:04 -03:00
committed by GitHub
co-authored by Claude Opus 5
parent c0df466172
commit 3da8de93df
+28
View File
@@ -43,6 +43,7 @@
#include <nlohmann/json.hpp>
#include <cmath>
#include <cstdint>
#include <limits>
#include <map>
@@ -286,11 +287,38 @@ template <> struct Codec<nlohmann::json, void> {
// This rejects rather than coerces, matching the rest of the codec: a value the
// declared type cannot represent must not reach business logic wearing a
// different one.
//
// A WHOLE-VALUED float is not such a value. JSON does not distinguish 3 from
// 3.0, and every encoder that sees a whole double may emit either — which is
// exactly the reasoning Codec<double> already gives for accepting an integral
// number. The two directions have to agree, so 3.0 decodes as 3 while 3.7 is
// still refused. A CLI that types its arguments by parsing (logoscore's does)
// produces 3.0 for `3.0`, so refusing it breaks callers over a spelling.
template <class T>
struct Codec<T, std::enable_if_t<std::is_integral_v<T> && !std::is_same_v<T, bool>>> {
static nlohmann::json to(T v) { return v; }
static T from(const nlohmann::json& j, const std::string& path)
{
if (j.is_number_float()) {
const double d = j.get<double>();
double intPart = 0.0;
if (std::modf(d, &intPart) != 0.0)
typeError(path, "integer", j);
// Strict bounds: double(int64max) rounds UP to 2^63, so `<=` would
// admit a value the cast cannot represent (undefined behaviour).
if constexpr (std::is_unsigned_v<T>) {
if (d < 0.0 || d >= 18446744073709551616.0) // 2^64
typeError(path, "unsigned integer in range", j);
} else {
if (d < -9223372036854775808.0 || d >= 9223372036854775808.0) // ±2^63
typeError(path, "signed integer in range", j);
}
const auto whole = static_cast<long double>(d);
if (whole < static_cast<long double>(std::numeric_limits<T>::min()) ||
whole > static_cast<long double>(std::numeric_limits<T>::max()))
typeError(path, "integer in range", j);
return static_cast<T>(d);
}
if (!j.is_number_integer() && !j.is_number_unsigned())
typeError(path, "integer", j);