bank: adopt the QML-surface drift guard, which meant giving the GUI a test target - #297
bank: adopt the QML-surface drift guard, which meant giving the GUI a test target#297Yaraslaut wants to merge 8 commits into
Conversation
`QmlSurfaceAudit` reported kanban's `BoardView.qml` as reading a property `BoardBridge` does not have. The QML is correct, and the audit was wrong. `deadLetterCount` exists only under `MORPH_BUILD_OFFLINE_SQLITE`, and the view guards its dead-letter banner with `deadLetterCount !== undefined` precisely so the binding stays hidden in a build without it -- the header's own comment says so. A read of that shape is a *question* about whether conditionally-compiled surface is present, not a use of it, and answering "it is absent" is the guard working. Reporting it as a finding was backwards: the only way to satisfy the audit would have been to delete the guard that makes the binding safe. `QmlScanResult::optionalProbes` now records `alias.member` for every member a file compares against `undefined` or applies `typeof` to, and the reverse-direction sweep skips a missing member the same file probed. Scoped to the probing file: a guard in one view says nothing about an unguarded read in another. Within a file it excuses every read of that member, which is deliberate -- the guard is normally written once, on the `visible:` binding that gates the rest. This is a narrowing rule, so it can only silence findings, never create them. That makes it exactly the kind of change that can quietly gut a guard, so it was mutation-tested rather than assumed: stripping the `!== undefined` from BoardView.qml makes the audit report 2 findings again, and restoring it returns to 0. The audit's own suite (17 cases) passes unchanged. It also disposes of what morph#240 called the blocker for adopting the audit in kanban. That issue framed the conditional surface as needing the audit to understand a surface that changes shape, because the hand-written guard asserts `propertyCount() - propertyOffset() == 7` or `== 5` depending on the switch. A per-name check never had that problem -- it reads the metaobject that was actually built, so an absent property is simply absent. The real gap was on the other side: the QML-to-bridge direction had no way to say "this member is optional and I handle its absence".
morph#240's remaining rungs, minus `bank` (its GUI has no test target at all, so adopting there means creating one -- a larger change, left for its own PR). Both rungs already guarded their bridges by hand, and both guards had the same blind spot: they never read a `.qml` file. lims loops over `methodCount()`; kanban keeps `indexOfMethod`/`indexOfSignal` lists plus an exact property count. Those catch a deletion, but a count is not a name -- it stays satisfied when one invokable is renamed and another added, which is the drift QML actually suffers. QML binds by string, so a renamed invokable or a `Connections` handler for a signal that no longer exists is not a compile error, not a test failure, and not a QML warning. The pane simply stays empty. Both audits found a pre-existing backlog on their first run, which is why the issue warned against assuming a clean result: 15 members in lims (morph#287), 9 in kanban (morph#291). Neither is a broken screen -- the other direction is clean in both -- but each member is either dead surface or a missing control, and deciding which is per-member work these files do not do. They are recorded itemised, the way ledger's already are (morph#239), so the guard goes live now and catches the *next* drift instead of waiting for the backlog to clear. The lists are checked in both directions: an exemption for a member that has since been deleted, or one QML has since bound, fails the test. They can only shrink deliberately. One of kanban's nine is in the list under protest, and morph#291 argues it: `syncStatusChanged` is the NOTIFY signal behind `deadLetterCount`, which BoardView.qml *does* bind. A property binding consumes a NOTIFY signal without an explicit handler and the audit does not model that. Teaching it to would change what ledger's list means too, so it is recorded rather than fixed in passing. kanban needed no conditional-surface handling in the end -- see the previous commit.
The whole-tree clang-format gate rejected examples/common/testkit/qml_surface.cpp: the boolean chain in protectLiterals() put its operators at line starts, and the declaration below it wrapped where it fits on one line. Purely how clang-format lays the same code out; no logic changes. Missed because the format check was not re-run after the probe-detection edit -- the gate this repo added in morph#210 exists precisely to catch that, and here it did.
Six more violations in the same push, in the file I added rather than the testkit I edited. Layout only. Both of these were caught by CI rather than by me: I ran the whole-tree gate while building morph#294's earlier commits and did not re-run it after the last two edits. The bank agent had already reported this file as unformatted and attributed the disagreement to its local clang-format 21 versus CI's 22 -- but my local is 22.1.8 and agrees with CI, so the report was right and the explanation was wrong. Taking it at face value cost a CI round trip.
…line sqlite
CI found what my local run could not: in a `MORPH_BUILD_OFFLINE_SQLITE=ON`
build, `BoardBridge::queueDepth` exists and no scanned .qml reads it --
BoardView.qml binds `deadLetterCount` and nothing reads `queueDepth`. A tenth
backlog entry, present in one configure only.
Guarded by the same macro as the property. The audit rejects an exemption
naming a member the bridge does not have, so an unguarded entry would fail the
OFF configure exactly as its absence failed the ON one.
This file previously claimed the two configures produce identical findings and
that the exemption list therefore needed no #ifdef. That was wrong, and the
check behind it was one I had already found unsound: its grep matched only the
bridge-to-QML direction and silently dropped the QML-to-bridge findings. Having
noticed that, I carried the conclusion forward anyway. The claim is now
replaced by what actually happened.
Verified in a *fresh* build directory per configure, which the previous attempt
was not: reconfiguring OFF -> ON does not reliably re-run AUTOMOC, so the
bridge kept a metaobject with no `queueDepth` while its compile line carried
the macro -- making the ON verification meaningless in a reused tree. Fresh ON:
the surface test passes 46 assertions and the audit is green. Fresh OFF: green.
The exemption is keyed off the macro rather than off
`indexOfProperty("queueDepth") >= 0`, and the file records why: the metaobject
form cannot go stale, which sounds better and is worse -- it agrees with
whatever was built, including a tree where macro and metaobject disagree, and
would have let exactly the stale build above pass unnoticed.
bank is the last of the three rungs morph#240 names, and the only one whose adoption is not a few lines: it builds gui/ as a standalone client with no test binary that links the controllers at all, so there was nowhere to put an audit. Why bank was structured that way: it is not a ladder rung. It predates the ladder, is absent from examples/rungs.txt, and never calls morph_add_rung() -- so the CONFIGURE_DEPENDS glob over tests/*.cpp that made lims and kanban need no CMake change never applied here. bank names its test sources by hand, and its controllers were compiled straight into the qt_add_executable(). Split bank_gui_lib (Qt6::Core only, AUTOMOC) out of bank_gui so the controllers can be linked by something that is not the desktop client, and add bank_gui_tests beside bank_tests. The split is safe for the WASM build, which compiles the same controller sources into bank_gui_wasm directly and never configures gui/ at all. This commit's test is a deliberate baseline: no exemptions, so the run prints bank's real backlog rather than a green light. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ted surface
Two gaps that a rung binding its bridges through
`QQmlContext::setContextProperty`, rather than `setInitialProperties`,
walks straight into. examples/bank is the first such rung to be audited.
1. `Connections { target: app }`. The scanner required an
`<id>.<alias>` target and skipped bare identifiers outright, on the
grounds that a bare identifier is a local `id`. It usually is -- but
in a context-property shell the bridge *is* a bare root-context name,
so all six of bank's Connections blocks were scanned as nothing at
all. Renaming every one of their `onError` handlers to a signal that
does not exist produced zero findings before this commit. A bare
target now counts when, and only when, it is an alias the audit was
handed; anything else is still ignored, because there is no way to
tell an unbound bridge from a Timer's id. Drift direction 4 therefore
does not reach this shape, and now says so.
2. Inherited members. `surfaceOf()` read only a class's own slice, and
both directions used it -- so a handler for a signal declared by a
shared base resolved against nothing and was reported as a broken
screen. bank's six controllers all inherit `error` from
`BankController`. A reference written in QML is now resolved against
everything the bridge can reach; the unreferenced-member sweep still
covers only what the bound class declares itself, since a base's
surface is the base's to answer for and reporting it once per derived
bridge is noise no rung could act on.
Four cases added to the mutation suite. Three of them fail against the
previous implementation, which is the point.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first run reported nine findings; four are a real backlog and five are the audit's documented blind spot met head on. The five: AppShell.qml keeps its page controllers in a `var` array and refreshes the visible page with `controllers[current].refresh()`. Every one of those five `refresh` invokables is reachable and exercised on every page switch, and none is visible to a text scan. They are exempted with that as their reason -- not with an issue number, because there is nothing to fix. Rewriting AppShell into a five-armed switch so a scanner could see the call would be bending the rung around its guard. The four that are real -- TransactionController's `selectedAccount` and its NOTIFY, its `posted`, and PayeeController's `paid` -- are itemised and tracked in morph#296. `selectedAccount` looks like an actual bug: MoveMoneyPage calls the property's WRITE and never reads it back, so the picker cannot show a selection `refresh()` made on its own. Neither direction of the audit reports a broken screen: no .qml file binds a name its controller lacks. Also record in TESTING.md and the bank README what bank taught the guard: the context-property shell shape, the inherited-surface split, and the fact that CI builds no part of native bank today, so this binary runs locally only. Closes #240 for bank -- the last of its three rungs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
529556b to
4ece80b
Compare
|
Rebased onto the current The failures on the previous run were inherited from the older base, not from this branch: Verified after the rebase, with both testkit changes stacked — the probe rule from #294 and the context-property/inherited-surface fixes here:
The testkit suite is up from 77/17 on master, which is the four mutation cases this branch adds — three of which fail against the previous audit implementation. |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Adopts
QmlSurfaceAuditinexamples/bank, the last of the three rungs #240names — and the only one where adopting meant building the place to put it.
This stacks on #294 (
fix/qml-surface-drift, still open at the time ofwriting), which is its base. The first two commits below are #294's; review
from
08732b4conward, or wait for #294 to merge.Why bank was different, verified rather than assumed
#240's table says bank has "no test target at all". That is half right, and the
half it gets wrong is the interesting one:
bank_testsdoes exist. It linksbank_liband Catch2 and drives themodels. What did not exist was any binary linking
gui/controllers/*— thecontrollers were compiled straight into the
qt_add_executable(bank_gui).examples/rungs.txt, nevercalls
morph_add_rung(), and is gated onMORPH_BUILD_BANK_EXAMPLE, anoption independent of
MORPH_BUILD_LADDER. That — not a missing argument ora deliberate opt-out — is why the
CONFIGURE_DEPENDSglob overtests/*.cppthat made
limsandkanbanneed no CMake change at all never applied here.bank predates the ladder and names its test sources by hand.
setContextProperty, notsetInitialProperties. Thepattern every other adopter follows assumes the latter. bank's controllers
are root-context names —
app,accounts,txns,cards,payees,loans— visible under the same name in all thirteen.qmlfiles, and nosub-view re-exposes one under a property of its own. So the alias mapping is
one
bind()per controller and nobindIn()at all, the opposite ofledger's doubled shape.
wasm-demo.ymltouches it, andonly its WebAssembly GUI.
bank_testshas never run in CI either. Sobank_gui_testsis a local-only target today, andexamples/TESTING.mdnowsays so rather than implying CI coverage this does not have.
What changed in the build
bank_gui_lib(STATIC,Qt6::Coreonly, AUTOMOC) is split out ofbank_gui,which keeps only
main.cppplus the QML module. Not one controller — norBankClient— includes a Quick or Qml header, so the audit links thecontrollers without a QML engine, matching the ladder's own
gui_libconvention.
Why it is safe for bank specifically:
gui/at all (examples/bank/CMakeLists.txtreturns early under
EMSCRIPTENafter adding onlygui_wasm/), andgui_wasmcompiles the same controller sources into its own executable byabsolute path. The split cannot reach it.
bank_gui_testsis gated onif(TARGET bank_gui_lib), so a-DMORPH_BUILD_BANK_EXAMPLE=ONconfigure withoutMORPH_BUILD_BANK_GUI— orwithout Qt at all — is unaffected.
apply_warnings(), for the same reasonbank_libdoes not: the third-party ORM headers it sees transitively are not-Werrorclean.QmlSurfaceAuditis compiled in as a source file rather than linked frommorph::ladder_testkit. That library only exists underMORPH_BUILD_LADDER=ON; requiring the ladder's whole shared testkit beforethe bank GUI can be tested would couple two unrelated option trees, and would
over-link besides (
morph_ladder_testkitpullsmorph::qt,Qt6::WebSockets,ladder_gui,ladder_app; the audit needsQt6::Core).It is the same translation unit, not a copy — the audit still has one
implementation.
tests/gui/, nottests/: bank lists its test sources by hand, so a file intests/deliberately absent frombank_testswould read as an oversight.Verified locally:
bank_gui,bank_testsandbank_gui_testsall build, andctest -R "bank controller"discovers and passes the new case.Two bugs in the audit, not in bank
bank is the first context-property shell the audit has ever seen, and it walked
straight into two gaps. Per #294's precedent these are fixed in the testkit
rather than worked around in the rung.
1.
Connections { target: app }was scanned as nothing at all. The scannerrequired an
<id>.<alias>target and skipped bare identifiers, on the groundsthat a bare identifier is a local
id. Usually true — but in acontext-property shell the bridge is a bare root-context name. Evidence, on
the unmodified audit: renaming all six of bank's
onErrorhandlers toonErrrrproduced zero findings.A bare target now counts when, and only when, it is an alias the audit was
handed. That deliberately gives up drift direction 4 (a
Connectionsblock ona bridge nobody bound) for this shape, because nothing distinguishes an unbound
bridge from a Timer's id — the header and
TESTING.mdnow say so.2. Inherited surface was resolved against the wrong slice.
surfaceOf()read only a class's own members and both directions used it, so a handler for a
signal a shared base declares resolved against nothing. bank's six controllers
all inherit
errorfromBankController. A QML reference now resolves againsteverything the bridge can reach; the unreferenced-member sweep still covers
only what the bound class declares itself, since a base's surface is the base's
to answer for and reporting it once per derived bridge is noise no rung could
act on.
Four cases added to the mutation suite; three fail against the previous
implementation, which is the point:
Full
ladder_common_tests: 544 assertions in 136 test cases, all passing.ladder_lims_tests "[qml-surface]"still passes, and the only bareConnectionstarget anywhere else in the tree iskanban'sBoardView.qml:369target: card, a local id that no rung binds — so thechange is inert for every existing adopter.
The backlog: nine findings, four of them real
First run, every exemption removed:
They split two ways, and the exemption reasons say which is which:
refreshcalls are not a backlog.AppShell.qml:12-14keeps thepage controllers in a
readonly property var controllers: [...]andrefreshes the visible page with
controllers[current].refresh(). Every oneis reachable and exercised on every page switch, and invisible to a text
scan — the audit's own documented blind spot. Exempted with that as the
reason, not an issue number. Rewriting
AppShellinto a five-armed switch soa scanner could see the call would be bending the rung around its guard.
TransactionController'sselectedAccount+ its NOTIFY, itsposted, andPayeeController::paid.selectedAccountlooks like an actual bug rather than dead surface —MoveMoneyPage.qmlcalls the property'sWRITEand never reads it back,while
TransactionController::refresh()auto-selects the first account whenthe selected one disappears, after which the picker and the controller
disagree about where a deposit lands, with no visible sign.
No member was deleted and no QML was written to make this green. The other
direction is clean: no
.qmlfile binds a name its controller lacks.The test can fail
Green as committed. Two simultaneous mutations — one per drift direction —
and it reports both, including the six
Connectionshandlers that wereinvisible before this branch:
Reproduction
No
QT_QPA_PLATFORM=offscreenneeded —bank_gui_testslinksQt6::Coreonly, no QML engine and no display.
macOS 15 (arm64), Apple clang 17.0.0, Qt 6.11.1 (CI pins 6.8.1), Debug.
Deliberately left out
the whole Lightweight/reflection-cpp/stdexec/libzip tree, and no bank target
has ever been in CI. That is its own change with its own cost argument;
documented here rather than smuggled in.
(ledger: four QML bridges publish 15 members no gui/qml file binds #239), lims (lims: two QML bridges publish 15 members no gui/qml file binds #287) and kanban (kanban: BoardBridge publishes nine members no gui/qml file binds #291) did.
examples/kanban/tests/test_kanban_qml_surface.cpp.It fails
clang-format --dry-run -Werrorunder clang-format 21, on a hunkthis branch does not touch. CI pins clang-format 22, under which lims, kanban: adopt the QML-surface drift guard, and stop the audit flagging guarded optional reads #294
presumably passes, so this is a version difference and reformatting it
locally would break it there. Every file this branch does touch is clean
under 21, and
scripts/check_spec_citations.shpasses.Closes #240 — bank was its last open rung, and with it the last open item of
#86, whose other two justified helpers (#168, #169) are already closed.