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
Conversation
4c82185 to
907f20b
Compare
|
|
…, 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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…#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
|
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 purged — The other four ( Staleness verdict: rebase, on the gate limb of the test. Since this PR's base ( Rebased onto 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 🤖 Generated with Claude Code |
907f20b to
e5b266b
Compare
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.qmlembedded 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.cppall 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:
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)normalizeLocaleNumbercompared one byte against['0','9'], so a user of anylocale whose digits are not ASCII could not enter a number at all — a flat
rejection, not a wrong value.
formatCanonicalNumberwas its consistentinverse: 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
Now measured,
QLocale::matchingLocalesunder Qt 6.11.2:zeroDigitCar_BHfa_IRff_Adlm_BFbgc_INas_INccp_BDnqo_GN,dz_BT,my_MM,sat_IN,mni_IN76 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
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
NOLINTBEGINblocks are gone, and that was verified rather thanassumed. There were three suppressions of
bugprone-easily-swappable-parametersin the header (onnormalizeLocaleNumber,on
formatCanonicalNumber, and ondetail::leadingSign). Before touchinganything I copied the header, stripped every suppression marker, and ran
clang-tidy 22.1.8 under this repo's own
.clang-tidy: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::leadingSignnow 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 itreports nothing, silently.
Behaviour
[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.
"٥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.
zeroDigitreads as ASCII"0", the reading an emptynegativeSigngets.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 B5as thedigit
'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:
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 theround trip passes. Mutation-checked rather than argued; each edge reverted in
turn, plus the mixing rule:
test_theDisplayEdgeEmitsTheLocalesDigits,test_thePairRoundTripsThroughEveryMeasuredDigitSettest_anEntryMayNotMixDigitFamiliesThe C++ display mutant's real output is the defect shape from the issue body:
The display edge also cross-checks against Qt itself: the bytes asserted for
ar_BHare exactly whatQLocale("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.WARN_AS_ERROR = FAIL_ON_WARNINGS: clean.qmllint: clean bar one pre-existingWizardView.qmlwarning.locale_format.hpp: clean, with no suppression ofbugprone-easily-swappable-parametersin the file.Findings filed, not folded in
cppcoreguidelines-pro-bounds-avoid-unchecked-container-accesson this headerat
0e3b8823, which the repo's own config makes an error. The#591rewritehappens to remove that line as a side effect; the issue is about the policy
and the tree-wide sweep, not that line.
deferred it; I hit the opposite-direction version of the hazard twice writing
render/forms: support native digit sets at the control edge, via a NumericLocale aggregate carrying a digit base #591, which is the evidence the issue carries.
Qt.locale(displayLocale)silently resolvesff_BFandmni_INto the wrong digit set, because the name drops the script subtag. Reported
as weak evidence: reproduced in Qt, no demonstrated morph consumer.
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