Summary
morph::render::normalizeLocaleNumber (include/morph/render/locale_format.hpp:52-100) is the single control-edge step that converts locale-formatted numeric entry into the canonical .-decimal text the exact Rational/Quantity digit routines consume. It accepts inputs it documents as malformed, including a sign injected immediately after the decimal separator.
Verification status
Reproduced by execution. Branch bridge-async-threading-contract, clang 22. Real output:
normalizeLocaleNumber(",-5", dec=",", grp=".") = ".-5"
normalizeLocaleNumber(".-5", dec=".", grp=",") = ".-5"
normalizeLocaleNumber("1-2", dec=".", grp=",") = nullopt <- control: correctly rejected
normalizeLocaleNumber(".", dec=".", grp=",") = "."
normalizeLocaleNumber(".5", dec=".", grp=",") = ".5"
normalizeLocaleNumber("5.", dec=".", grp=",") = "5."
The "1-2" control shows the sign guard works — but only once a digit has been emitted.
Mechanism
sawAnyOutput is set only at the bottom of the loop (:91). The decimal-separator branch appends '.' to canonical and then continues (:66-74), skipping that assignment. So after a decimal separator the function still believes it has produced no output, and the chr == '-' guard at :79-83 lets the sign through.
The comment on that guard states the intent exactly, and the code does not implement it:
"Leading position of the output: a stripped group separator before the sign would otherwise make an injected sign look leading."
Two documented contracts broken
:40-41 — "a sign anywhere but the leading position … yields std::nullopt". False for ",-5".
:33-34 — the @brief promises output matching -?[0-9]+(\.[0-9]+)?. The final check (:95-97) rejects only "" and "-", so ".", ".5", "5." and ".-5" are all returned as canonical.
The QML mirror disagrees
src/qt/forms/qml/DynamicForm.qml:817-836 is documented at :807 as mirroring this function, but tests i !== 0 against the stripped string and therefore rejects ",-5". Two implementations that are declared mirrors give different answers for the same input — which is the more serious half of this, since it means one of the two edges validates and the other does not.
Test gap
tests/test_render_locale_format.cpp covers "1-2" (sign after a digit) but no case where the sign follows a separator, which is why the guard's incompleteness is invisible.
What would change the verdict
- Close it if downstream parsing is shown to reject
".-5"/"5." safely and the two doc claims are corrected instead — but that would leave the C++ and QML edges disagreeing, which needs resolving either way.
- The fix is one line: set
sawAnyOutput = true in the decimal-separator branch (and tighten the final check to the documented grammar if the "."/"5." shapes also matter).
Summary
morph::render::normalizeLocaleNumber(include/morph/render/locale_format.hpp:52-100) is the single control-edge step that converts locale-formatted numeric entry into the canonical.-decimal text the exactRational/Quantitydigit routines consume. It accepts inputs it documents as malformed, including a sign injected immediately after the decimal separator.Verification status
Reproduced by execution. Branch
bridge-async-threading-contract, clang 22. Real output:The
"1-2"control shows the sign guard works — but only once a digit has been emitted.Mechanism
sawAnyOutputis set only at the bottom of the loop (:91). The decimal-separator branch appends'.'tocanonicaland thencontinues (:66-74), skipping that assignment. So after a decimal separator the function still believes it has produced no output, and thechr == '-'guard at:79-83lets the sign through.The comment on that guard states the intent exactly, and the code does not implement it:
Two documented contracts broken
:40-41— "a sign anywhere but the leading position … yieldsstd::nullopt". False for",-5".:33-34— the@briefpromises output matching-?[0-9]+(\.[0-9]+)?. The final check (:95-97) rejects only""and"-", so".",".5","5."and".-5"are all returned as canonical.The QML mirror disagrees
src/qt/forms/qml/DynamicForm.qml:817-836is documented at:807as mirroring this function, but testsi !== 0against the stripped string and therefore rejects",-5". Two implementations that are declared mirrors give different answers for the same input — which is the more serious half of this, since it means one of the two edges validates and the other does not.Test gap
tests/test_render_locale_format.cppcovers"1-2"(sign after a digit) but no case where the sign follows a separator, which is why the guard's incompleteness is invisible.What would change the verdict
".-5"/"5."safely and the two doc claims are corrected instead — but that would leave the C++ and QML edges disagreeing, which needs resolving either way.sawAnyOutput = truein the decimal-separator branch (and tighten the final check to the documented grammar if the"."/"5."shapes also matter).