lims, kanban: adopt the QML-surface drift guard, and stop the audit flagging guarded optional reads - #294
Open
Yaraslaut wants to merge 5 commits into
Open
lims, kanban: adopt the QML-surface drift guard, and stop the audit flagging guarded optional reads#294Yaraslaut wants to merge 5 commits into
Yaraslaut wants to merge 5 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.
This was referenced Aug 25, 2026
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.
Yaraslaut
force-pushed
the
fix/qml-surface-drift
branch
from
August 25, 2026 17:07
74bb73a to
03eebae
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adopts
QmlSurfaceAuditinlimsandkanban— two of #240's three remainingrungs.
bankis left for its own PR: its GUI has no test target at all, soadopting there means creating one.
Why a count was not enough
Both rungs already guarded their bridges by hand, and both guards had the same
blind spot: neither ever reads a
.qmlfile.limsloops overmethodCount();kanbankeepsindexOfMethod/indexOfSignallists plus anexact
propertyCount() - propertyOffset()assertion.Those catch a deletion. But a count is not a name — it stays satisfied when one
invokable is renamed and another added, which is precisely the drift QML
suffers. QML binds by string, so a renamed invokable, or a
Connectionshandler 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.
The interesting part: the bug was in the audit, not in kanban
The audit reported
BoardView.qmlas reading a propertyBoardBridgedoes nothave. The QML is correct and the audit was wrong.
deadLetterCountexists only underMORPH_BUILD_OFFLINE_SQLITE, and the viewguards its dead-letter banner with
deadLetterCount !== undefinedexactly sothe binding stays hidden 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. Reporting it was backwards: the only way to satisfy
the audit would have been to delete the guard that makes the binding safe.
So a read compared against
undefined(or undertypeof) is now understood asa probe, scoped to the probing file.
This also changes what #240 called the blocker, though not as cleanly as I
first wrote. The issue framed kanban's conditional surface as needing "the
audit to have a notion of a conditional surface", because the hand-written
guard asserts
== 7or== 5depending on the switch. A per-name check doesnot have that problem — it reads the metaobject that was actually built, so
an absent property is simply absent.
But the exemption list does need a branch, and an earlier version of this PR
claimed otherwise.
queueDepthexists only underMORPH_BUILD_OFFLINE_SQLITEand is unbound in the configure where it exists —
BoardView.qmlbindsdeadLetterCountand readsqueueDepthnowhere. So it is a tenth backlogentry visible in one configure only, and its exemption carries the same
#ifdefas the property.CI caught that; my local run could not, because it had the switch off. Worse,
the two-configure comparison I based the original claim on was one I had
already found unsound — its grep matched only the bridge-to-QML direction and
dropped the QML-to-bridge findings — and I carried the conclusion forward
regardless.
Re-verified in a fresh build directory per configure, which the first
attempt was not: reconfiguring OFF → ON does not reliably re-run AUTOMOC, so
the bridge kept a metaobject without
queueDepthwhile its compile linecarried the macro. Fresh ON: surface test 46 assertions, audit green. Fresh
OFF: green.
Mutation evidence
A narrowing rule can only silence findings, never create them — which makes it
exactly the kind of change that can quietly gut a guard. So it was tested both
ways rather than assumed:
BoardView.qmlThe audit's own suite (17 cases, 77 assertions) passes unchanged.
Backlogs, recorded rather than swallowed
#240 warned that a clean result should not be assumed for any rung, and it was
right — both found one on the first run:
Neither is a broken screen; the other direction is clean in both. Each member
is either dead surface or a missing control, and deciding which is per-member
work these files do not do. They are itemised the way
ledger's already are(#239), so the guard goes live now and catches the next drift instead of
waiting for the backlog to clear. Both lists are checked in both directions —
an exemption for a member since deleted, or one QML has since bound, fails the
test — so they can only shrink deliberately.
One of kanban's nine is listed under protest and argued in #291:
syncStatusChangedis theNOTIFYbehinddeadLetterCount, whichBoardView.qmldoes bind. A property binding consumes aNOTIFYsignalwithout 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 thanfixed in passing.
Verification
limsaudit: green.kanbanaudit: green.check_spec_citations.shgreen.The four existing adopters, re-run against this change — the regression
that a narrowing rule most plausibly breaks. This was still building when the PR
was opened; it has since finished, all green:
bookmarkspastebinpollsledger(
pastebin,bookmarksandpollstag their audit[qml-bridges]rather than[qml-surface], so the figures above cover their whole bridge suite.ledgerand the two rungs added here use
[qml-surface]. That inconsistency predatesthis PR and is left alone.)
Advances #240 (lims, kanban;
bankremains) and, with it, the last open itemof #86 — whose other two justified helpers, #168 and #169, are already closed.