Skip to content

render/forms: escape the bidi controls in the QML i18n tests, and support native digit sets at both control edges (fixes #610, fixes #591) - #630

Merged
Yaraslaut merged 2 commits into
masterfrom
laneG-batch-610-591
Sep 20, 2026
Merged

Yaraslaut merged 2 commits into
masterfrom
laneG-batch-610-591

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Two commits, one per ticket, in this order deliberately: #610 is mechanical and
lands regardless, and doing it first meant #591's edits landed on already-escaped
source.

fixes #610
fixes #591


1. forms/tests: spell the bidi controls in tst_i18n.qml as escapes (#610)

Nine assertion lines in src/qt/forms/tests/tst_i18n.qml embedded U+061C,
U+200E and U+200F as raw code points while the morph#583 block sixty lines
above, the corpus #609 added, and the C++ mirror in
tests/test_render_locale_format.cpp all spell the same characters explicitly.
Seventeen raw code points become \uXXXX. No string value changes.

How that was proved, since the suite cannot prove it. A green run is not
evidence here: a mangled invisible character still leaves the suite green,
because ASCII '+' is accepted in every locale by design (morph#596). So:
every double- and single-quoted literal in the file was extracted and its
QML/JS escapes decoded, before and after, and the two hex dumps of the decoded
values are byte-identical (408 literals). Negative control — a harness that
drops one from the converted file is detected by that same comparison,
so it is measuring something.

The issue's own acceptance command:

$ grep -cP '[\x{061C}\x{200E}\x{200F}]' src/qt/forms/tests/tst_i18n.qml
0
$ grep -rlP '[\x{061C}\x{200E}\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}]' src/ tests/ examples/
(no output)

The repo-wide lint the issue floats as optional is deliberately not here —
it is a policy decision. Filed as #628 instead, with evidence: I hit the
opposite-direction version of the same hazard twice while writing #591.

2. render/forms: carry the locale facts in a NumericLocale (#591)

normalizeLocaleNumber compared one byte against ['0','9'], so a user of any
locale whose digits are not ASCII could not enter a number at all — a flat
rejection, not a wrong value. formatCanonicalNumber was its consistent
inverse: it copied the canonical ASCII digits out and wrapped them in the
locale's separators and sign. That is why the pair round-tripped and the gap
was invisible from either side alone, and it is why both edges and both QML
mirrors move together.

The measurement the issue left open

Which of Qt's 711 locales actually use non-ASCII digits was not
enumerated. The original defends 24 from sign data only and says so.

Now measured, QLocale::matchingLocales under Qt 6.11.2:

zeroDigit locales e.g.
U+0030 604 C
U+0660 26 ar_BH
U+06F0 19 fa_IR
U+1E950 12 ff_Adlm_BF
U+0966 8 bgc_IN
U+09E6 4 as_IN
U+11136 2 ccp_BD
U+07C0 / U+0F20 / U+1040 / U+1C50 / U+ABF0 1 each nqo_GN, dz_BT, my_MM, sat_IN, mni_IN

76 of 711, across eleven sets. Two of them (Chakma, Adlam) are outside the
BMP, so a digit is four UTF-8 bytes and two UTF-16 units — which is why both
scans decode a code point rather than comparing a unit, and why the test
corpus includes them.

The aggregate, and the suppression

struct NumericLocale {
    std::string_view decimalSeparator = ".";
    std::string_view groupSeparator;
    std::string_view negativeSign     = "-";
    std::string_view positiveSign     = "+";
    std::string_view zeroDigit        = "0";
};

Both edges take it; the QML mirrors take the parallel object literal. No
back-compatible positional overload — two spellings of one call is how the two
edges drifted apart to begin with.

The NOLINTBEGIN blocks are gone, and that was verified rather than
assumed.
There were three suppressions of
bugprone-easily-swappable-parameters in the header (on normalizeLocaleNumber,
on formatCanonicalNumber, and on detail::leadingSign). Before touching
anything I copied the header, stripped every suppression marker, and ran
clang-tidy 22.1.8 under this repo's own .clang-tidy:

locale_format.hpp:189:67: error: 2 adjacent parameters of 'leadingSign' of similar type ('std::string_view') are easily swapped by mistake [bugprone-easily-swappable-parameters,...]
locale_format.hpp:318:71: error: 2 adjacent parameters of 'normalizeLocaleNumber' of similar type ('std::string_view') ...
locale_format.hpp:441:56: error: 2 adjacent parameters of 'formatCanonicalNumber' of similar type ('std::string_view') ...

Three sites, matching the three suppressions exactly — so the check does fire
here, and deleting the markers is not a no-op. After the change, with no
suppression of that check anywhere in the header, the same command reports
nothing at all, across the full check list. detail::leadingSign now takes
(rest, const NumericLocale&), which is why its block could go too.

One methodological note that cost me a wrong conclusion and is worth passing
on: this only reproduces when the header's path matches HeaderFilterRegex
(include/morph/.*). Point clang-tidy at a copy under another path and it
reports nothing, silently.

Behaviour

  • Entry accepts a digit in [zeroDigit, zeroDigit+9] or in ['0','9'];
    display emits only the locale's. The header states this next to the existing
    sign reasoning, because it is morph#596's rule applied to digits: the
    locale's own digits are on the user's keyboard only if their keyboard has
    them, and an extra accepted spelling cannot change a value because the
    canonical output always spells digits in ASCII.
  • Mixed-digit entry is rejected. "٥5" is malformed, not "55"
    the recommended choice, matching the existing "a sign anywhere but the leading
    position is malformed" strictness. Pinned by a test on each edge, since
    accepting it was equally implementable.
  • An empty or undecodable zeroDigit reads as ASCII "0", the reading an empty
    negativeSign gets.

Default output is byte-identical — asserted, not assumed

A 1680-case sweep (14 locale configurations × 60 entries × both edges, including
malformed UTF-8) run against the pre-change header and against this one produces
identical output. Negative control: removing the decoder's overlong-encoding
guard changes 13 of those rows, so the sweep measures something — and that guard
is load-bearing for exactly this reason. The digit test is a range test on a
decoded code point, so a lenient decoder would read the overlong C0 B5 as the
digit '5' in the default locale, where the old byte-range scan rejected it.

The acceptance test, and why it has two halves

A round trip over a native-digit corpus, on both edges, over all eleven sets:

normalizeLocaleNumber(formatCanonicalNumber(canonical, loc), loc) == canonical

asserted byte-for-byte, and the intermediate display text asserted to
contain no ASCII digit. The second half is not decoration. The round trip alone
does not fail for the half-fix this ticket exists to prevent — if only the
entry edge moved, display still emits '5', entry accepts ASCII digits, and the
round trip passes. Mutation-checked rather than argued; each edge reverted in
turn, plus the mixing rule:

mutant result
C++ display edge reverted 2 cases / 100 assertions fail
C++ entry edge reverted 4 cases / 106 assertions fail
C++ mixing allowed 1 case fails
QML display edge reverted test_theDisplayEdgeEmitsTheLocalesDigits, test_thePairRoundTripsThroughEveryMeasuredDigitSet
QML entry edge reverted 4 functions fail
QML mixing allowed test_anEntryMayNotMixDigitFamilies

The C++ display mutant's real output is the defect shape from the issue body:

CHECK( formatCanonicalNumber("-1050.25", {...ar_BH...}) == "\xD8\x9C-\xD9\xA1..." )
with expansion:
  "؜-1٬050٫25" == "؜-١٬٠٥٠٫٢٥"

The display edge also cross-checks against Qt itself: the bytes asserted for
ar_BH are exactly what QLocale("ar_BH").toString(-1050.25) produces.

Local results (incomplete — CI not waited on)

  • morph_tests: 1548 cases / 22880 assertions, 1 failed-as-expected (pre-existing).
  • [locale] subset: 403 assertions in 49 cases.
  • QML suite: 292 passed, 0 failed (284 before; +8 functions).
  • Doxygen with WARN_AS_ERROR = FAIL_ON_WARNINGS: clean.
  • qmllint: clean bar one pre-existing WizardView.qml warning.
  • clang-tidy 22.1.8 over locale_format.hpp: clean, with no suppression of
    bugprone-easily-swappable-parameters in the file.

Findings filed, not folded in

Still not verified

Whether any shipped rung is configured to an affected locale — the issue's own
priority-raising trigger — remains unanswered. I measured the locale set, not
the rung configurations.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk

@Yaraslaut

Copy link
Copy Markdown
Member Author

Yaraslaut added a commit that referenced this pull request Sep 20, 2026
…, and clear clang-tidy batch A (fixes #627, fixes #600) (#631)

* lint: make four NOLINTNEXTLINE directives apply, and gate the ones that cannot (fixes #627)

A NOLINTNEXTLINE annotates the next *physical* line. Four directives in the
tree had their reason wrapped onto a second comment line, so each annotated
that comment instead of the code, and clang-tidy reported nothing about it --
the directive parses, the file looks annotated, and the findings leak.

Measured on 0e3b882, clang-tidy 22.1.8, with the CI clang-tidy job's own
option set. Before, the four leaked six findings:

  forms.hpp:489:39  forwarding reference parameter 'action' is never forwarded
  forms.hpp:489:57  forwarding reference parameter 'visitor' is never forwarded
  forms.hpp:496:41  possibly unsafe 'operator[]'
  forms.hpp:496:66  possibly unsafe 'operator[]'
  oom_injector.cpp:110:21  do not manage memory manually
  oom_injector.cpp:110:9   initializing non-owner with a newly created owner

After, all six are gone. The fourth directive, test_bridge_lifetime.cpp:519,
was inert *and* unnecessary -- cppcoreguidelines-owning-memory does not fire on
placement new, confirmed by measurement -- so it is now merely effective, and
kept so a later edit to that line cannot reintroduce the finding silently.

Each site moves its reason above the directive rather than adding a
clang-format guard, so the fix survives reformatting. forms.hpp:487's directive
also dropped an unchecked-container-access it never needed; only
missing-std-forward fires on that line, and the reason now says why neither
parameter may be forwarded rather than restating the check's name.

The guard is the part that matters. scripts/check_nolint_directives.sh fails
when a NOLINTNEXTLINE is followed by a comment, a blank line, or nothing at
all. Run against unmodified master it reports exactly the four sites above, at
exactly those line numbers, and it does not flag fixed_string.hpp:48 -- the
prose that documents this hazard and whose existence is why the scan anchors
the directive at the start of the comment. That anchoring is a stated residual,
not an oversight.

scripts/test_check_nolint_directives.sh drives the checker against
tests/lint/nolint_directives/: the two effective shapes must be accepted, each
inert shape rejected on its own while naming its own file, and a directory with
no directives at all rejected rather than called clean. A gate for suppressions
that suppress nothing would be the same defect one level up if it were not
itself tested.

The job belongs in drift-guard.yml and is in its own workflow only because that
file and ci.yml are both held by open PRs (#614, #623); the workflow's header
says so and folding it in changes nothing about its behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk

* lint: clear clang-tidy batch A, and set the campaign's suppression precedents (fixes #600)

#580's census of 596 findings splits into six batches by file tree. Batch A is
the tail -- version/attributes/journal/render/qt-forms/detail -- and it is first
not because it is smallest but because it is the only batch that forces every
policy precedent the other five need, on a corpus where getting one wrong is
cheap. `include/morph/render/locale_format.hpp` is excluded: PR #630 rewrites it
and changes its answer, so its one finding must be re-measured, not inherited.

Measured on 0e3b882, clang-tidy 22.1.8, .clang-tidy unmodified, with the CI
clang-tidy job's own configure. Nine TUs -- the VIHS stub for each batch-A
header plus quantity.hpp's stub, examples/forms/gui_qml/FormsController.cpp and
tests/test_quantity.cpp for the findings a standalone header stub cannot reach
because it instantiates nothing. Before, deduplicated by path+line+column, that
reproduces the census row for row:

  7  include/morph/version.hpp
  4  include/morph/journal/action_log.hpp
  4  include/morph/qt/forms/forms_controller_core.hpp
  2  include/morph/journal/file_action_log.hpp
  1  include/morph/attributes.hpp
  1  include/morph/detail/quantity_equation.hpp
  1  include/morph/render/i18n.hpp
  --
  20

After: 0. The same run still reports 261 findings elsewhere under
include/morph/, so it analysed the tree rather than failing to.

Fixed rather than suppressed, four checks:

  readability-use-concise-preprocessor-directives (1) -- attributes.hpp's
  `#if defined(__has_cpp_attribute)` is now `#ifdef`. This is #600's finding.

  readability-redundant-member-init (4) -- action_log.hpp's `std::string x{}`
  members drop the initializer. std::string's default constructor is
  non-trivial, so aggregate and default initialization are unchanged.

  readability-identifier-length (1) -- file_action_log.hpp's `std::ifstream in`
  becomes `input`.

  performance-unnecessary-value-param (4) -- forms_controller_core.hpp's
  submitIfValid/fetchOptions took `std::string` by value and passed it to
  executeJson, whose parameters are `std::string_view`. Neither was ever moved,
  so the copies bought nothing; both are now `const std::string&`, which is
  source-compatible. examples/bookmarks' mirror moves with it -- its own doc
  comment asserts it has the same body as this one, and that claim has to stay
  true.

  cppcoreguidelines-pro-bounds-avoid-unchecked-container-access (1) --
  file_action_log.hpp's `lines[i]` becomes `lines.at(i)`, hoisted out of the
  try/catch that surrounds it. The loop condition already bounds `i`, so the
  check cannot fire; if it ever could, std::out_of_range inside that try would
  be caught and mis-reported as a malformed journal line.

Suppressed with a reason, three checks -- the precedents the remaining batches
inherit, written to the standard set by render/locale_format.hpp:181:

  macro-to-enum + macro-usage (7) -- version.hpp's macros are the `#if`-testable
  half of the version API. An enumerator is invisible to the preprocessor and a
  constexpr function cannot be called from a `#if`, so the checks do not propose
  a different spelling, they propose removing the capability. Unfixable by
  construction; the constants the checks ask for already exist beside them,
  defined from the macros so the two cannot drift.

  bugprone-easily-swappable-parameters (1) -- render/i18n.hpp's resolveText.
  The check is right that swapping derivedKey and schemaLiteral would be silent.
  They stay because the order is the documented resolution chain, mirrored
  parameter-for-parameter by DynamicForm.qml's resolveText.

  misc-header-include-cycle (1) -- quantity_equation.hpp's include back into
  quantity.hpp. The cycle is real as a graph statement and deliberate as a
  design: it is closed by `#pragma once` and it is what makes the header
  analysable standalone. The remedy the check proposes is the state this file
  was moved away from.

No bare NOLINT anywhere: each suppression says why the check is wrong at that
site, not what the check is called.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.67442% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
include/morph/render/locale_format.hpp 97.67% 1 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

Yaraslaut and others added 2 commits September 20, 2026 21:23
…#610)

The morph#596 block embedded U+061C, U+200E and U+200F as raw code points
while the morph#583 block sixty lines above, the corpus PR #609 added, and
the C++ mirror in tests/test_render_locale_format.cpp all spell the same
characters explicitly. Two idioms for one kind of value, and one of the two
renders as nothing -- a reviewer cannot read what line 310 asserted without
a hexdump, and a copy-paste through a tool that strips bidi controls would
delete one silently while the suite stayed green, because ASCII '+' is
accepted in every locale by design (morph#596).

Seventeen raw code points across nine assertion lines become \uXXXX escapes.
No string value changes, which is the whole point, so it is proved rather
than asserted: every double- and single-quoted literal in the file was
extracted and its QML/JS escapes decoded, before and after, and the two
hex dumps of the decoded values are identical. As a negative control, a
harness that drops one ‎ from the converted file is detected by that
same comparison, so it is measuring something.

The issue's own acceptance command now returns nothing:

    $ grep -cP '[\x{061C}\x{200E}\x{200F}]' src/qt/forms/tests/tst_i18n.qml
    0

and tree-wide over the full trojan-source class (bidi controls plus the
embedding, override and isolate ranges) `grep -rlP` over src/ tests/
examples/ matches no file at all. The QML suite is green: 284 passed,
0 failed.

The repo-wide lint the issue floats as optional is deliberately not here;
it is a policy decision and belongs in its own issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk
…ded (fixes #591)

normalizeLocaleNumber compared one byte against ['0','9'], so a user of any
locale whose digits are not ASCII could not enter a number at all -- a flat
rejection, not a wrong value. formatCanonicalNumber was its consistent
inverse: it copied the canonical ASCII digits out unchanged and wrapped them
in the locale's separators and sign, which is exactly why the pair
round-tripped and the gap was invisible from either side alone. Teaching the
entry edge to accept U+0665 while the display edge kept emitting '5' would
satisfy the bug report and break the round trip forms.md requires, so both
edges and both QML mirrors move together.

  Measured, QLocale::matchingLocales under Qt 6.11.2, all 711 locales:
  76 report a zeroDigit other than ASCII '0', across eleven sets --
  U+0660 (26), U+06F0 (19), U+1E950 (12), U+0966 (8), U+09E6 (4),
  U+11136 (2), and one each of U+07C0, U+0F20, U+1040, U+1C50, U+ABF0.

The issue's open question was the size of that set: it could defend 24 from
*sign* data and said so. 76 is the measured figure, and two of the sets are
astral, so a digit is up to four UTF-8 bytes and two UTF-16 units -- both
scans now decode a code point rather than comparing a unit.

The locale facts travel as one aggregate rather than six positional views.
That is not tidiness: five adjacent swappable string_views already needed a
clang-tidy suppression for bugprone-easily-swappable-parameters, with a
paragraph of justification, on each of the two functions and on the sign
helper. A sixth would have weakened that argument. The aggregate deletes all
three suppressions -- verified, not assumed: with every suppression removed
from a copy of the header, clang-tidy 22.1.8 under this repo's .clang-tidy
reported the check at three sites before and none after, and the header is
now clean under the full check list with no suppression of that check in it.
There is no back-compatible positional overload; two spellings of one call is
how the edges drifted apart to begin with.

Behaviour: entry accepts a digit in [zeroDigit, zeroDigit+9] or in ['0','9'],
display emits only the locale's. That asymmetry is morph#596's rule for signs
applied to digits, and the header says so next to the sign reasoning. An entry
that mixes the two families ("٥5") is malformed rather than read as 55 --
a decision, so a test on each edge records it. An empty or undecodable
zeroDigit reads as ASCII "0", the reading an empty negativeSign gets.

zeroDigit defaults to "0", so every existing caller is byte-identical --
asserted rather than assumed. A 1680-case sweep (14 locale configurations x 60
entries x both edges) was run against the pre-change header and against this
one; the two dumps are identical. Negative control: removing the decoder's
overlong-encoding guard changes 13 of those rows, so the sweep measures
something. That guard is load-bearing for exactly this reason -- the digit
test is a range test on a decoded code point, so a lenient decoder would read
the overlong C0 B5 as the digit '5' in the *default* locale.

The acceptance test is the round trip over a native-digit corpus, on both
edges, over all eleven sets. It asserts byte equality *and* that the display
text contains no ASCII digit, because the round trip alone does not fail for
the half-fix: entry accepts ASCII digits, so a display edge still emitting
them round-trips perfectly. Mutation-checked rather than assumed --
reverting each edge in turn, and the mixed-family rule:

  C++   display edge reverted -> 2 test cases fail, 100 assertions
          "؜-1٬050٫25" == "؜-١٬٠٥٠٫٢٥"
  C++   entry edge reverted   -> 4 test cases fail, 106 assertions
  C++   mixing allowed        -> 1 test case fails
  QML   display edge reverted -> test_theDisplayEdgeEmitsTheLocalesDigits,
                                 test_thePairRoundTripsThroughEveryMeasuredDigitSet
  QML   entry edge reverted   -> 4 functions fail
  QML   mixing allowed        -> test_anEntryMayNotMixDigitFamilies

Green afterwards: morph_tests 1548 cases / 22880 assertions (1 failed as
expected, pre-existing), the [locale] subset 403 assertions in 49 cases, and
the QML suite 292 passed / 0 failed. Doxygen with FAIL_ON_WARNINGS builds
clean, and so does qmllint bar one pre-existing WizardView warning.

forms.md states the round trip in terms of the digit base, including the
"and no ASCII digit in the display text" half, since that is the part a
faithful-looking implementation drops. morph#583 is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk
@Yaraslaut

Copy link
Copy Markdown
Member Author

Landing sweep, 2026-09-20 21:30 CEST — rebased, not merged.

The previous run (35502221064) was red on 5 of 51 checks, and its logs are purgedgh api .../jobs/106060003242/logs returns BlobNotFound, so there is nothing left to read. Only one of the five named a failing step at all:

Linux / all optional features (clang)   → Test (offscreen Qt platform)

The other four (Application ladder, Application ladder / ASan+UBSan, Linux / all optional features (gcc), Linux / clang-ubsan) recorded no failing step and all completed within 51 s of each other (10:15:11Z–10:16:02Z), which is the shape of a cancellation rather than five independent defects. That is inferred from the job timings, not measured — the logs that would settle it are gone.

Staleness verdict: rebase, on the gate limb of the test. Since this PR's base (0e3b8823), master has moved .github/workflows/ci.yml, drift-guard.yml, suppression-guard.yml, CMakePresets.json and added check_workflow_option_coverage.py, check_nolint_directives.sh and its self-test. Its CI was judged by rules that no longer exist. #617 also landed in that window, so a red leg now names every failing test rather than the first — a rebased run diagnoses itself better than a re-run of the old one would.

Rebased onto a8511aa6, cleanly, no conflicts. The three newly-landed gates this branch had never been judged by were run locally against the rebased tree first:

NOLINT directive lint OK: 165 NOLINTNEXTLINE directive(s), all annotating code.
ok: 7 structured citation(s) in scripts/mutation_survivors.json resolve to the line they name.
(check_workflow_option_coverage.py: every declared MORPH_BUILD_* option accounted for)

So the new gates are not the cause. Not verified: the Qt offscreen test itself — not built locally. If the fresh run is red on Test (offscreen Qt platform) again, it is a real defect in this branch and the next sweep will dispatch a lane with the surviving log.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant