Skip to content

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
masterfrom
fix/qml-surface-drift
Open

lims, kanban: adopt the QML-surface drift guard, and stop the audit flagging guarded optional reads#294
Yaraslaut wants to merge 5 commits into
masterfrom
fix/qml-surface-drift

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Aug 25, 2026

Copy link
Copy Markdown
Member

Adopts QmlSurfaceAudit in lims and kanban — two of #240's three remaining
rungs. bank is left for its own PR: its GUI has no test target at all, so
adopting 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 .qml file. lims loops over
methodCount(); kanban keeps indexOfMethod/indexOfSignal lists plus an
exact 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 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.

The interesting part: the bug was in the audit, not in kanban

The audit reported 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 exactly so
the 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 under typeof) is now understood as
a 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 == 7 or == 5 depending on the switch. A per-name check does
not 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. queueDepth exists only under MORPH_BUILD_OFFLINE_SQLITE
and is unbound in the configure where it existsBoardView.qml binds
deadLetterCount and reads queueDepth nowhere. So it is a tenth backlog
entry visible in one configure only, and its exemption carries the same
#ifdef as 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 queueDepth while its compile line
carried 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:

tree findings
guard stripped from BoardView.qml 2 reported
guard restored 0

The 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:
syncStatusChanged is the NOTIFY 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.

Verification

  • lims audit: green. kanban audit: green.
  • Audit's own suite: 17 cases / 77 assertions, unchanged.
  • Probe rule mutation-tested in both directions (table above).
  • clang-format silent; check_spec_citations.sh green.

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:

rung result
bookmarks 224 assertions / 13 cases
pastebin 189 assertions / 8 cases
polls 106 assertions / 7 cases
ledger 2 assertions / 1 case

(pastebin, bookmarks and polls tag their audit [qml-bridges] rather than
[qml-surface], so the figures above cover their whole bridge suite. ledger
and the two rungs added here use [qml-surface]. That inconsistency predates
this PR and is left alone.)

Advances #240 (lims, kanban; bank remains) and, with it, the last open item
of #86 — whose other two justified helpers, #168 and #169, are already closed.

`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.
@Yaraslaut
Yaraslaut force-pushed the fix/qml-surface-drift branch from 74bb73a to 03eebae Compare August 25, 2026 17:07
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 63.63636% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
examples/common/testkit/qml_surface.cpp 63.63% 0 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant