One policy engine, and a GUI that asks before it writes - #48
Merged
Conversation
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>
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.
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
Unambiguousand 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:
on a windows-1252 file the CLI refuses.
ConversionPolicyTests.TheGuiSequenceRefusesWhatTheCliRefusesis 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:
ConversionPolicyThe 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
-Applyre-asserts the gate from what the plan recorded instead of recomputing it. That property is preserved deliberately and tested.The confirmation
Convertnow 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-Applyhas. A preview run is its own answer and skips the dialog, since it writes nothing to confirm.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 existingCurrentCharsetLabeloverride 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
-Frommeans, 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
ConversionOrchestratordoes the whole of it — decide, ask, carry out what was agreed — with the confirmation as a delegate and the real conversion engine underneath.MainFormkeeps 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:
.bakTwo gaps that only appeared once the sequence was testable
-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.Detection is counted, not asserted architecturally
DetectionCountersrecords every time EC works out an encoding or classifies one. Measured, not claimed:-Plan(3 files)-ApplyTest parallelism is disabled so those counts mean something; the suite runs in two seconds.
"No classification is not a safe state", throughout
ConversionReportEntry.Ambiguityis nullable, an explicit source recordsUnambiguousrather 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.