Skip to content

fix(#327): ExportDialog's 22 bindings were all on reflection — one scope, no templates, and IsVisible defaults to true - #372

Merged
tylerkron merged 3 commits into
mainfrom
fix/327-exportdialog-compiled-bindings-a3cd7f
Sep 17, 2026
Merged

tylerkron merged 3 commits into
mainfrom
fix/327-exportdialog-compiled-bindings-a3cd7f

Conversation

@tylerkron

@tylerkron tylerkron commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Part of #327 — one view, Daqifi.Desktop/View/ExportDialog.axaml. The ticket is a checklist and stays open.

What was wrong

All 22 of the export dialog's bindings resolved by reflection at run time. Daqifi.Avalonia.csproj sets AvaloniaUseCompiledBindingsByDefault to false and the view declared neither x:DataType nor x:CompileBindings, so a renamed or deleted member rendered blank — or, for a bool, sat at its default, and IsVisible defaults to true — while desktop, iOS and every test stayed green.

Three lines on the root put all 22 in front of the XAML compiler. No other markup changed — no binding was rewritten, moved or deleted.

That default matters more here than in most views: this dialog is three full-size panels stacked in one Grid cell, and IsVisible is the only thing keeping two of the three off the screen. A gate that failed to resolve did not hide a panel — it showed one, on top of the state that should have been visible.

The count is 22, re-derived by parsing

#327's table lists this view at 22 and that number holds exactly. Parsed rather than grepped, with a brace-depth scanner so the StringFormat={}{0}% escape on line 100 is not double-counted and a {Binding} inside a comment would not be counted at all:

kind count
{Binding …} 22
{MultiBinding} / {TemplateBinding} / {ReflectionBinding} 0
total 22

The same parser was validated against the two views whose parsed totals are already on record: SummaryFlyout.axaml → 29 (matching #370), MainWindow.axaml → 29 {Binding} + 9 {TemplateBinding}.

One scope, not several. The view contains no DataTemplate at all, so every one of the 22 resolves against the root x:DataType and there is no per-template question to answer here.

Escape hatches: accounted for, and now guarded

Grepped and parsed, before and after:

hatch occurrences before after
x:CompileBindings="False" on any subtree 0 0
{ReflectionBinding} 0 0

Both are now assertions (The_view_opens_no_escape_hatch), because either one puts part of the file back on reflection with the root declaration still in place and the build still green — so the count above and the guards below would both be measuring less than they claim.

Controls

Positive control, main side — one build, both defects

This is the control that makes the negative results below mean something, and it is taken as a single compilation so no second build has to be trusted. On the merge base c45fe32, with both defects applied to ExportDialog.axaml at once:

ExportDialog.axaml(76,14,76,14): Avalonia error AVLN2000: Unable to resolve suitable
  regular or attached property ZzzBogusPropertyControl on type
  Avalonia.Controls:Avalonia.Controls.ProgressBar Line 76, position 14.
    1 Error(s)

Exactly one error — the bogus CLR property — and not one word about {Binding ZzzExportResultMessageTypo} twelve lines further down the same file. The XAML compiler demonstrably read this exact file content and chose silence about the binding. A stale or skipped build cannot produce that pair.

(Recorded because it nearly cost a false negative elsewhere: after a build fails in the XAML step, a bare re-run can report Build succeeded having compiled nothing. Every control here was taken off a fresh edit, and success was read from the process exit code, never from grepping for the word "succeeded" — -tl:on writes ANSI escapes into that string.)

Negative controls, branch side — one typo at a time, four shapes

Each applied alone, each a fresh edit, each its own build. All four are the file's distinct binding shapes rather than four samples of the easiest one:

shape typo on c45fe32 on this branch
plain Text path ExportResultMessageZZZ silent (see above) AVLN2000 … on type 'Daqifi.Desktop.ViewModels.ExportDialogViewModel' (113,24)
negated IsVisible !IsAverageQuantityValidZZZ silent AVLN2000 … 'ExportDialogViewModel' (76,32)
Command ExportLoggingSessionsCommandZZZ silent AVLN2000 … 'ExportDialogViewModel' (91,42)
path carrying StringFormat={} ExportProgressZZZ silent AVLN2000 … 'ExportDialogViewModel' (100,24)

Each names the right type, and each build produced exactly one AVLN2000 for this file.

No dead binding in this view

All 22 members exist on ExportDialogViewModel — the branch builds clean with no markup edits. #327 warns to expect some; this one was clean, like #335 and #370 before it.

What the new tests guard, and what they deliberately do not

Member names are now the compiler's business for all 22 at once, so they are not asserted. ExportDialogBindingTests covers only what a green build cannot see — and each of the four was run against its own mutation, which it caught:

guard mutation caught by
the two root declarations, by parsing the root element delete x:CompileBindings from the tag The_dialog_declares_its_data_type
the x:DataType claim vs. the object the dialog is handed ShowDialogAsync<ExportDialog>(this, this) The_declared_scope_is_the_view_model_the_dialog_is_actually_handed
the three state panels each keep their own gate remove IsVisible from the exporting panel Each_of_the_three_states_is_gated_by_its_own_flag
the success/failure fork swap the Open Folder / Try Again gates The_result_buttons_follow_the_result

Qodo round 1 found both guards weaker than they read, and both are fixed in a2e01c1 — the reviewer's own two bypasses now fail, and would have passed before:

bypass before now
x:CompileBindings = 'False' — spaces and single quotes, valid XML passed fails
the literal in a comment, file otherwise clean failed (false positive) passes
ShowDialogAsync<ExportDialog>(this, (object)vm) — a shape the regex cannot parse silently skipped fails

Both guards now work on parsed markup rather than raw text, the call-site scan covers every .cs file in Daqifi.Avalonia rather than one, and an unparsed call fails loudly instead of being skipped — silent omission was the actual defect. The Roslyn semantic model Qodo recommended was deliberately not taken: the test project references no compiler API, and "fail on a shape I cannot verify" gives the same guarantee for far less machinery.

The second one is worth spelling out, because nothing in the type system closes it: the dialog has no DataContext of its own and is handed one by IDialogService.ShowDialogAsync<T>(object ownerViewModel, object viewModel), whose view-model parameter is typed object. ShowDialogAsync<ExportDialog>(this, this) compiles, verified — and is caught only by this guard. An x:DataType pointed at a type that happens to share the member names compiles too, and renders blank.

The fourth is not cosmetic either: Open Folder on a failed export opens the folder the export could not write, and Try Again after a successful one invites a second export over the first. #312's whole subject was this dialog claiming success it had not earned.

Not tested, because the build already rejects it: anything about member names, and a DataTemplate added to this view — per the corrected rule in #327's comments, ContentControl.ContentTemplate and resource-dictionary templates infer nothing and are a hard AVLN2000 even with correct member names, while an ItemsControl/ListBox ItemTemplate infers correctly and needs no declaration. A test for either would only restate the compiler, which is the opposite of this ticket's point. The view's own comment says which case is which, so the next person does not add an x:DataType by reflex — a plausible-but-wrong one overrides the inference and is worse than none.

Also here — two claims this change makes false

  • ExportDialogViewModel.IsAverageQuantityValid's remark said it is "Bound by ExportDialog.axaml by reflection". Corrected.
  • ExportResultHonestyTests' markup-half test said "both halves of this fix resolve by reflection", and paired each AssertBinds with an AssertExposes. The prose is corrected and the two AssertExposes calls are dropped: renaming either member is now an AVLN2000, so those assertions can no longer fail — and an assertion the build cannot let fail reads as coverage and is none. The AssertBinds half stays, because compiled bindings type-check the path and never the target: moving the !IsAverageQuantityValid gate from IsVisible to IsEnabled compiles, and leaves the reason line permanently on screen.

BindingFacts' own "inherited scope" wording is #336's ground and is untouched here.

Verification

  • Desktop 148` first-party warnings`, iOS 148`` — measured against my own merge-base c45fe32, both sides force-rebuilt (`--no-incremental`) in this worktree with the same counter. Zero first-party warnings added. No constant quoted.
  • dotnet test: 905 / 905 passing, 9 of them new (ExportDialogBindingTests). Re-derived after the last commit: the count went 906 → 905 in Qodo round 1, when the escape-hatch [Theory] with two InlineData became one [Fact] that checks strictly more — one fewer test case, more coverage. The two dropped AssertExposes calls were assertions inside an existing [Fact], so they cost no test.
  • dotnet restore run without -r; git status --short -- '**/packages.lock.json' is empty, so no NU1004 lock-file drift.
  • Visual gate: 39 / 39, and the denominator is checked rather than assumed — it equals the committed macos-arm64.sha256's 39 lines, and 39 PNGs were actually captured. dialog-export-configure.png and dialog-export-failed.png are byte-identical, as are the other 37. This dialog is a captured screen (dialog-export-configure.png, dialog-export-failed.png), and a pure x:DataType addition should move zero pixels.
  • The Android head does not build on this Mac; CI covers it. Not claimed here.
  • Built with none of the other open PRs applied: this branch is c45fe32 + five files (the fifth, BindingFacts.cs, is a one-word visibility change — RepoRoot private → internal — added in round 1 so the call-site guard can walk the project). Zero file overlap with fix(#327): SummaryFlyout's 29 bindings were all on reflection — two nested template scopes, both inferred #370 (SummaryFlyout.axaml + its tests) or chore(warning-zero): MVVMTK0034 x5 — read the generated properties, not the [ObservableProperty] backing fields #368 (LoggedSeriesLegendItem.cs + its test); chore(deps): Bump the minor-and-patch group with 17 updates #363 is Dependabot and red on every head, untouched.

🤖 Generated with Claude Code

tylerkron and others added 2 commits September 16, 2026 21:51
…ope, no templates

AvaloniaUseCompiledBindingsByDefault is false for this project, so a view that
declares no x:CompileBindings has every {Binding} in it resolved by reflection at
run time: a renamed, moved or deleted view-model member fails silently while both
heads build green.

Measured on origin/main in a SINGLE build carrying two defects at once: a bogus CLR
property on the ProgressBar was AVLN2000 naming this file, proving the compiler read
it, while a {Binding} at a member that does not exist — same file, same compilation —
produced no diagnostic at all.

x:DataType="vm:ExportDialogViewModel" + x:CompileBindings="True" on the root makes
all 22 the compiler's business. The view contains no DataTemplate, so there is
exactly one scope and nothing to declare per-template.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… in ExportDialog

Member names are now the compiler's business for all 22 bindings at once, so they are
not asserted. What is left, one test each, all four proven to fail under a mutation:

- the two root declarations, asserted by PARSING the root element — the comment beside
  them spells out both literals, so the substring form passes with the attributes
  deleted from the tag;
- the x:DataType CLAIM against the object the dialog is actually handed. The dialog has
  no DataContext of its own and gets one from
  ShowDialogAsync<T>(object ownerViewModel, object viewModel), whose view-model
  parameter is typed object: ShowDialogAsync<ExportDialog>(this, this) COMPILES, and is
  caught only by this guard;
- which attribute each binding feeds. The three state panels share one Grid cell and
  only IsVisible separates them, and a binding that does not resolve leaves its target
  at the property default — IsVisible defaults to TRUE, so an ungated panel draws over
  the state that should be on screen. Same for the ExportSucceeded fork: Open Folder on
  a failed export opens the folder the export could not write.

Also corrects two claims this change makes false: ExportDialogViewModel's remark that
IsAverageQuantityValid is "Bound by ExportDialog.axaml by reflection", and
ExportResultHonestyTests' markup-half test, whose two AssertExposes calls the compiler
now owns. Those were dropped rather than left in place — an assertion the build can no
longer let fail reads as coverage and is none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

tylerkron commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Ready for review. One view from #327 — Daqifi.Desktop/View/ExportDialog.axaml, 22 bindings parsed — plus the guards for what compiled bindings still cannot see.

The positive control is a single build carrying both defects: one AVLN2000 for a bogus CLR property and total silence on a binding typo in the same file, same compilation. No second build has to be trusted, so a stale incremental result cannot fake it.

Build gate green on desktop and iOS, 906/906 tests, visual gate 39/39 against a 39-line manifest (denominator checked, not assumed), zero first-party warnings against my own merge-base measurement of c45fe32 — the warning sets are byte-identical, not just the counts.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Compile ExportDialog bindings and guard visibility contracts

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Compile all 22 ExportDialog bindings against ExportDialogViewModel at build time.
• Guard binding scope, runtime data context, visibility gates, and reflection escape hatches.
• Remove redundant member-exposure assertions now enforced by Avalonia's XAML compiler.
Diagram

graph TD
  Host["Dialog Host"] -->|"passes instance"| View["Export Dialog"] -->|"declares scope"| Compiler["XAML Compiler"] -->|"validates paths"| Bindings["22 Bindings"] -->|"drive gates"| Panels["State Panels"]
  VM["Export ViewModel"] -->|"typed scope"| View
  Tests["Binding Tests"] -->|"parse contracts"| View
  Tests -->|"inspect call sites"| Host
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Enable compiled bindings project-wide
  • ➕ Protects every compatible view without repeated root declarations.
  • ➕ Makes compiled bindings the default for future markup.
  • ➖ Broadens this checklist item into a potentially disruptive migration.
  • ➖ Views with ambiguous or template-specific scopes may require separate fixes.
2. Strongly type the dialog service
  • ➕ Makes the runtime view-model handoff compile-time safe.
  • ➕ Eliminates the need to inspect call sites as source text.
  • ➖ Requires a broader dialog-service API and call-site refactor.
  • ➖ Does not validate XAML target attributes or visibility polarity.

Recommendation: Keep the PR's view-scoped compiled-binding migration because it fixes all 22 bindings without unrelated markup changes and matches the incremental #327 checklist. A strongly typed dialog service is a worthwhile follow-up for runtime scope safety, while a project-wide default should be handled as a separate migration with per-view template validation.

Files changed (4) +313 / -10

Bug fix (1) +23 / -0
ExportDialog.axamlEnable compiled bindings for all 22 dialog bindings +23/-0

Enable compiled bindings for all 22 dialog bindings

• Declares ExportDialogViewModel as the single root binding scope and explicitly enables compiled bindings. This turns missing or renamed binding members into build errors instead of silent runtime failures that could leave stacked panels visible.

Daqifi.Avalonia/Daqifi.Desktop/View/ExportDialog.axaml

Tests (2) +286 / -8
ExportResultHonestyTests.csRemove binding-member assertions superseded by XAML compilation +22/-8

Remove binding-member assertions superseded by XAML compilation

• Removes reflection-based checks that ExportDialogViewModel exposes two bound members because compiled bindings now enforce those paths. Retains assertions that the bindings target the intended Text and IsVisible attributes, which compilation cannot verify.

Daqifi.Avalonia.Tests/Exporter/ExportResultHonestyTests.cs

ExportDialogBindingTests.csAdd compiled-binding and visibility contract tests +264/-0

Add compiled-binding and visibility contract tests

• Adds parsed XAML assertions for the root data type, compilation setting, namespace mapping, and absence of reflection escape hatches. It also verifies dialog call sites construct the declared view-model type and preserves the three panel gates plus success/failure button and icon polarity.

Daqifi.Avalonia.Tests/Views/ExportDialogBindingTests.cs

Documentation (1) +4 / -2
ExportDialogViewModel.csDocument compile-time validation of the quantity validity binding +4/-2

Document compile-time validation of the quantity validity binding

• Updates IsAverageQuantityValid documentation to explain that its binding path is now compiler-checked while the target attribute remains protected by tests.

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/ExportDialogViewModel.cs

@qodo-code-review

qodo-code-review Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Escape-hatch guard misses valid syntax ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
The_view_opens_no_escape_hatch searches raw text for only the exact spelling
x:CompileBindings="False" instead of inspecting XAML attributes. A descendant using valid
equivalent XML such as x:CompileBindings = 'False' restores reflection bindings while this guard
still passes.
Code

Daqifi.Avalonia.Tests/Views/ExportDialogBindingTests.cs[R260-263]

+    [InlineData("x:CompileBindings=\"False\"")]
+    [InlineData("ReflectionBinding")]
+    public void The_view_opens_no_escape_hatch(string hatch) =>
+        Assert.DoesNotContain(hatch, BindingFacts.Source(View), StringComparison.Ordinal);
Relevance

●●● Strong

Recent precedent favors XML-aware assertions over raw substring checks for XAML declarations and
equivalent syntax.

PR-#325
PR-#335

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new test passes a single exact literal to an ordinal raw-source search, while the repository's
existing helper documentation explicitly distinguishes raw substring checks from parsed XAML
attribute checks. XML permits whitespace around = and single-quoted attribute values without
changing the attribute's meaning.

Daqifi.Avalonia.Tests/Views/ExportDialogBindingTests.cs[259-263]
Daqifi.Avalonia.Tests/BindingFacts.cs[54-68]
Daqifi.Avalonia.Tests/BindingFacts.cs[71-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The escape-hatch test searches for one exact textual spelling, so valid XML formatting variants can disable compiled bindings without failing the guard.

## Fix Focus Areas
- Daqifi.Avalonia.Tests/Views/ExportDialogBindingTests.cs[246-263]

## Recommended Fix
Parse the view as XML and inspect every descendant's XAML-namespace `CompileBindings` attribute, interpreting its value as a Boolean and rejecting any explicit false value. Keep a separate markup-extension check for `ReflectionBinding`, but do not use raw substring matching for XML attributes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Type guard skips dialog calls ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
The_declared_scope_is_the_view_model_the_dialog_is_actually_handed recognizes only calls with two
positional identifier arguments and then searches the whole file independently for a matching
constructor assignment. An additional call using an expression, named argument, different
formatting, or another source file is ignored while the two existing matches keep callSites.Count
positive, allowing an incompatible object to reach the dialog without failing this guard.
Code

Daqifi.Avalonia.Tests/Views/ExportDialogBindingTests.cs[R124-127]

+        var host = BindingFacts.Source(Host);
+
+        var callSites = Regex.Matches(
+            host, @"ShowDialogAsync<ExportDialog>\(\s*[A-Za-z_][A-Za-z0-9_]*\s*,\s*(?<arg>[A-Za-z_][A-Za-z0-9_]*)\s*\)");
Relevance

●●● Strong

Recent reviews accept guards that prevent false positives from broad scans and crafted syntax
bypasses.

PR-#338
PR-#314

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The regex requires the exact generic method spelling and two positional identifier arguments, and
the constructor check is a separate file-wide textual search. The current host happens to use that
exact shape twice, so either match keeps the positive-count assertion satisfied even if a new
unsupported invocation is added; the test's own documentation confirms the service parameter is
typed as object.

Daqifi.Avalonia.Tests/Views/ExportDialogBindingTests.cs[91-100]
Daqifi.Avalonia.Tests/Views/ExportDialogBindingTests.cs[124-148]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[2837-2850]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[2855-2867]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The raw-source regex recognizes only one invocation shape and does not associate the passed expression semantically with its type, so unsupported call forms can bypass the new scope guard.

## Fix Focus Areas
- Daqifi.Avalonia.Tests/Views/ExportDialogBindingTests.cs[109-148]

## Recommended Fix
Replace the regex checks with C# syntax and semantic analysis that enumerates every `ShowDialogAsync<ExportDialog>` invocation across the desktop project and verifies that its view-model argument has the `ExportDialogViewModel` type. Ensure unsupported or unresolved invocation forms fail the test rather than being silently omitted.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Cross-repo context — repo relationships
Review mode: 🚀 Fast: The push only strengthens localized binding-coverage tests and exposes a helper, with no runtime behavior or high-risk contract changes.

Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

… stop skipping call shapes

Finding 1 (escape-hatch guard misses valid syntax). Accepted and fixed. The guard
searched raw text for one exact spelling, and XML permits
`x:CompileBindings = 'False'` — spaces around the `=` and single quotes — which means
the hatch could be opened with the guard still green. It was wrong in the other
direction too: a comment explaining the hatch would have failed a clean file. Both
halves now work on the PARSED markup — every descendant's XAML-namespace
CompileBindings attribute, read as a Boolean, and {ReflectionBinding} found by scanning
parsed attribute values and text nodes rather than the file. Same lesson as Qodo round 2
on PR #325, applied to the hatches instead of the declarations.

Finding 2 (type guard skips dialog calls). Accepted; the underlying defect was real —
a call shape the regex could not parse was SILENTLY skipped while the two it understood
kept `callSites.Count > 0` satisfied, so the guard reported coverage it did not have.
Fixed by making that case loud rather than silent: every ShowDialogAsync<ExportDialog>
occurrence is counted across the whole Daqifi.Avalonia project, and the test FAILS when
any occurrence is not in the (owner, local) shape it can follow.

Deliberately NOT the recommended Roslyn semantic model: the test project references no
compiler API, and "fail on a shape I cannot verify" gives the same protection — nothing
unverified passes — at a fraction of the machinery.

Both fixes are mutation-checked, including Qodo's own two bypasses, each of which now
fails and would have passed before:
  - x:CompileBindings = 'False' (spaces + single quotes)  -> fails
  - x:CompileBindings="False" spelled plainly             -> fails
  - a real {ReflectionBinding} on one binding             -> fails
  - the literal in a COMMENT on an otherwise clean file   -> passes (no false positive)
  - a call shape the guard cannot parse                   -> fails

Test count 906 -> 905: the escape-hatch [Theory] with two InlineData became one [Fact]
that checks strictly more. One fewer test case, more coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Round 1: both findings accepted and fixed in a2e01c1, both threads answered and resolved.

Both were the same species — a guard that read as protection and provided less — which is the exact failure this PR exists to remove, so they were worth catching here of all places.

  • Escape-hatch guard: now parses the markup instead of searching the text. Your x:CompileBindings = 'False' bypass fails now and passed before; a comment mentioning the hatch no longer fails a clean file.
  • Call-site guard: the real defect was that an unparsed call shape was silently skipped while the two understood calls kept the count positive. It now scans every .cs in Daqifi.Avalonia and fails loudly on any shape it cannot follow. I did not take the Roslyn recommendation and said why in the thread.

Both fixes mutation-checked, including the two bypasses you named. Suite 905/905 (906 → 905 because an escape-hatch [Theory] of two cases became one [Fact] checking strictly more). PR body re-derived against the final diff.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit a2e01c1

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