Skip to content

One policy engine, and a GUI that asks before it writes - #48

Merged
amrali-eg merged 2 commits into
masterfrom
feat/gui-ambiguity-confirmation
Aug 27, 2026
Merged

One policy engine, and a GUI that asks before it writes#48
amrali-eg merged 2 commits into
masterfrom
feat/gui-ambiguity-confirmation

Conversation

@amrali-eg

@amrali-eg amrali-eg commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Phase E — one policy engine, and a GUI that asks before it writes

Building the GUI on the same model as the CLI turned up the reason it needed to be.

The GUI was not applying the ambiguity refusal at all

Classification ran only during a Convert-mode scan. The GUI scans in Detect mode and converts the rows the user checks, so every entry reached conversion carrying the default Unambiguous and the gate never fired.

The tool converted, on the strength of whatever detection returned, the exact files it tells CLI users it will not convert. Nothing failed, because no test drove the GUI's sequence — View, then Convert — as a sequence.

Verified before fixing, not inferred:

classification=Unambiguous  result=Converted  converted=True

on a windows-1252 file the CLI refuses. ConversionPolicyTests.TheGuiSequenceRefusesWhatTheCliRefuses is that probe, kept.

This is the same lesson as the audit: a safety rule enforced at one call site is a safety rule the next call site does not have.

The fix: ConversionPolicy

Detection / explicit source → classification → PlannedAction → CLI / GUI / plan

The decision moves into one place and every route asks it. It is asked at the point of decision rather than during the scan, which is what makes it reachable from all three.

Entries carry the answer, and an entry that reaches a plan without one now raises rather than defaulting to Convert — defaulting is the exact shape of the bug.

Applying a plan still does not classify again: an entry that arrives already decided keeps its decision, so -Apply re-asserts the gate from what the plan recorded instead of recomputing it. That property is preserved deliberately and tested.

The confirmation

Convert now runs a WhatIf pass that decides, then a pass that carries out what was confirmed. Both use the same entry objects, so the second classifies nothing — the conversion that happens is the one that was shown, the same property -Apply has. A preview run is its own answer and skips the dialog, since it writes nothing to confirm.

Convert 417 of 480 selected file(s) to utf-8 without BOM

  386  Encoding determined by the file's own bytes             Will convert.
   31  Encoding undetermined, every reading agrees on the text  Will convert; the label
                                                                is a choice, the content
                                                                is not.
   22  Encoding undetermined, readings disagree on the text     WILL NOT be converted.
   39  Already in the target encoding                           Nothing to do.
    2  Encoding could not be identified                         Left alone.

Directory        C:\Source
Source encoding  detected per file
Backups          enabled — each original kept as <file>.bak
Guarantees       strict codecs, verified output, atomic install, ambiguity refusal

For refusals it names the encodings actually in conflict, per file, and offers the one thing that resolves it — saying which encoding they are.

Explicit source ≠ bypass safety

The GUI's selection is -From, using the engine's existing CurrentCharsetLabel override rather than a new path. It replaces detection for those files and nothing else. Tested: the choice changes the resulting text (so it is a decision, not decoration); bytes that cannot strictly decode as the chosen encoding are still refused; a failed backup still aborts.

Scope note: the selection applies to the refused files — the ones the dialog is asking about — rather than the whole run. Same mechanism as -From, narrowed to the question being answered.

Three test files started failing, correctly

They construct entries directly and convert single-byte content. Once the gate actually reached them, it refused — rightly. They name a source encoding rather than having it detected, which is what -From means, and they now say so.

The dialog is tested

On an STA thread, against real plans: every outcome mix, nothing-refused, everything-refused, that it names competing encodings rather than reporting low confidence, that it says whether originals are kept, and that it reports the plan it was handed rather than recounting a directory that has since changed.

It is the only part of the safety model a GUI user reads, and was the only part no test had ever executed.

402 passing.

🤖 Generated with Claude Code


Second round: the orchestration gap, closed

The defect was not in a component. Every component was correct and tested; the sequence between them was not, and it lived in button handlers and background-worker callbacks where nothing could run it. Fixing the defect without fixing that leaves the same gap open for the next one.

The sequence is now a class

ConversionOrchestrator does the whole of it — decide, ask, carry out what was agreed — with the confirmation as a delegate and the real conversion engine underneath. MainForm keeps the thread marshalling and nothing else.

14 orchestration tests, against real files. Every case that must not modify anything reads the source bytes before and after:

case outcome
text-changing ambiguity refused, bytes unchanged
text-equivalent ambiguity converted
unambiguous converted
explicit source chosen that codec is what reads the bytes
explicit source, undecodable still refused
user cancels nothing modified, not even a .bak
file changes after planning whole run stops, neither file touched
backup fails source untouched
target cannot hold the content source untouched
preview never asks, never writes
undecided entry stops rather than converting

Two gaps that only appeared once the sequence was testable

  1. The GUI's second pass did not carry the planned hashes. It had no equivalent of -Apply's staleness check — a file could change while the user read the dialog and be converted anyway. It now runs the same all-or-nothing check and binds each entry to the bytes it was approved for.
  2. An explicit encoding was applied to every refused file at once — wrong for the same reason applying it to the whole batch would be. The choice now carries its scope, the dialog ticks the files it applies to, and the button says how many. Tested with French windows-1252 beside Russian koi8-r: answering for one leaves the other refused and byte-identical.

Detection is counted, not asserted architecturally

DetectionCounters records every time EC works out an encoding or classifies one. Measured, not claimed:

operation detections classifications
GUI View (3 files) 3 0
at the confirmation +0 3
GUI execution pass +0 +0
-Plan (3 files) 3 3
-Apply 0 0
reading a plan 0 0

Test parallelism is disabled so those counts mean something; the suite runs in two seconds.

"No classification is not a safe state", throughout

ConversionReportEntry.Ambiguity is nullable, an explicit source records Unambiguous rather than relying on a field default, and the policy refuses an unclassified file. It refuses rather than throwing because it runs inside a parallel conversion loop, where refusing leaves every file intact.

422 passing.

Still manual, as agreed: a real Windows GUI smoke test.

amrali-eg and others added 2 commits August 27, 2026 05:15
Building the GUI on the same model as the CLI turned up the reason it needed to
be: the GUI was not applying the ambiguity refusal at all.

Classification ran only during a Convert-mode scan. The GUI scans in Detect mode
and converts the rows the user checks, so every entry reached conversion carrying
the default "unambiguous" and the gate never fired. The tool converted, on the
strength of whatever detection returned, the exact files it tells CLI users it
will not convert. Nothing failed, because no test drove the GUI's sequence -
View, then Convert - as a sequence. ConversionPolicyTests now does, and the first
version of that test failed on the classification before it ever reached the
result.

So the decision moves into ConversionPolicy, and every route asks it: the CLI's
Convert scan, a written plan, and the GUI. It is asked at the point of decision
rather than during the scan, which is what makes it reachable from all three.
Entries carry the answer, and an entry that reaches a plan without one now raises
rather than defaulting to Convert - defaulting is the exact shape of the bug.

Applying a plan still does not classify again. An entry that arrives already
decided keeps its decision, so -Apply re-asserts the gate from what the plan
recorded instead of recomputing it, which is the property the plan exists for.

The GUI's Convert now runs twice: a WhatIf pass that decides, and a pass that
carries out what was confirmed. Both use the same entry objects, so the second
does not classify anything - the conversion that happens is the one that was
shown. A preview run is its own answer and skips the dialog, since it writes
nothing to confirm.

The dialog keeps the three outcomes apart: an encoding the bytes determine, several
codecs that agree on the text, several that disagree. Only the third is refused,
and for it the dialog names the encodings actually in conflict and offers the one
thing that resolves it. That selection is the GUI's -From: it replaces detection
for those files and nothing else. Strict decoding, output verification and the
backup requirement all still apply, and there are tests for each.

Three test files that constructed entries directly began failing once the gate
reached them. They were right to: they name a source encoding rather than having
it detected, which is what -From means, and they now say so.

Also covers the dialog itself, on an STA thread, against real plans - every
outcome mix, nothing-refused, everything-refused, and that it reports the plan it
was handed rather than recounting a directory that has since changed. It is the
only part of the safety model a GUI user reads and was the only part no test had
ever executed.

402 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The defect this branch found was not in a component. Every component was correct
and tested; the sequence between them was not, and the sequence lived in button
handlers and background-worker callbacks where nothing could run it end to end.
Fixing the defect without fixing that leaves the same gap open for the next one.

So the sequence is a class. ConversionOrchestrator does the whole of it - decide,
ask, carry out what was agreed - with the confirmation as a delegate and the real
conversion engine underneath. MainForm keeps the thread marshalling and nothing
else. Fourteen tests drive it against real files: each of the three
classifications, an explicit source choice, a cancel, a stale plan, a failed
backup, content the target cannot hold, a preview, and an undecided entry. Every
case that must not modify anything reads the source bytes before and after.

Two gaps that only showed up once the sequence was testable. The GUI's second
pass did not carry the planned hashes, so it had no equivalent of -Apply's
staleness check - a file could change while the user read the dialog and be
converted anyway. It now runs the same all-or-nothing check and binds each entry
to the bytes it was approved for. And an explicit encoding was applied to every
refused file at once, which is wrong for the same reason applying it to the whole
batch would be: a batch can hold refused files in different encodings. The choice
now carries its scope, the dialog ticks the files it applies to, and the button
says how many.

"Detection happens once" was an architectural claim about all three surfaces.
This project has already been caught by one of those. It is now counted:
DetectionCounters records every time EC works out an encoding or classifies one,
and the tests assert the counts. A GUI conversion detects once per file at View
and never again - not while building the plan, not while the dialog is open, not
while writing. A plan detects N and classifies N; applying it does neither.
Reading a plan costs nothing. Test parallelism is disabled so those counts mean
something; the suite runs in two seconds.

The "no classification is not a safe state" rule now holds throughout rather than
at the plan boundary: ConversionReportEntry.Ambiguity is nullable, an explicit
source records Unambiguous rather than relying on a field default, and the policy
refuses an unclassified file rather than converting it. It refuses instead of
throwing because it runs inside a parallel conversion loop, where refusing leaves
every file intact.

422 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@amrali-eg
amrali-eg merged commit 87bbf1f into master Aug 27, 2026
1 check passed
@amrali-eg
amrali-eg deleted the feat/gui-ambiguity-confirmation branch August 27, 2026 02:39
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