From 985ce442988c9b2c09f998f5456d4ed75187ad7a Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 28 Oct 2026 18:04:10 +0000 Subject: [PATCH] cli: Say why no correction is shown Recovery entry, `create --existing` and `correct` now name what happened when no correction is offered: the search found none, the search ran out of time, or several corrections are possible. Each ends with the same advice to check the backup and re-enter it carefully. None of them says how to edit the entry. One private helper in correction.py writes the message, so a GUI can use the same wording. NO_CORRECTION and the three separate `correct` messages are gone. `_suggestions` now also returns whether the search completed. Claude-Session: https://claude.ai/code/session_01Q1TNm1YeYW8K9W2eBvxmCD --- src/codex32/_cli_input.py | 35 ++++++++------ src/codex32/cli.py | 14 ++---- src/codex32/correction.py | 11 +++++ tests/test_cli.py | 71 ++++++++++++++++++----------- tests/test_correction_disclosure.py | 8 ++-- 5 files changed, 85 insertions(+), 54 deletions(-) diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 6303a63e..b25bdcea 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -22,7 +22,13 @@ parse_codex32, recover_secret, ) -from codex32.correction import CorrectionCandidate, CorrectionContext, _best, _capture_mass +from codex32.correction import ( + CorrectionCandidate, + CorrectionContext, + _best, + _capture_mass, + _no_correction_message, +) from codex32.errors import ( CodexError, DuplicateShareIndex, @@ -47,9 +53,6 @@ _line_editor = None -NO_CORRECTION = "No unique correction found. Check your backup and re-enter it carefully." - - class InputError(Exception): pass @@ -545,21 +548,21 @@ def _suggestions( *, allowed: Callable[[CorrectionCandidate], bool] | None = None, fingerprint: Callable[[MasterSeed], bytes] | None = None, -) -> tuple[CorrectionCandidate, ...]: +) -> tuple[tuple[CorrectionCandidate, ...], bool]: fingerprint_match = _fingerprint_matcher(fingerprint) erased = value if interpretation := _case_interpretation(value, prefix, profiles, allowed): candidate, value, erased, prefix = interpretation if candidate is not None: - return (candidate,) + return (candidate,), True separator = value.lower().rfind("1") if separator <= 0: - return () + return (), True hrp = value[:separator].lower() rules = _optional_profile_rules(hrp) profile = rules.profile if rules is not None else None if profiles is not None and profile not in profiles: - return () + return (), True excluded = tuple(artifact.header.index for artifact in accepted) target = len(accepted[0].text) if accepted else None immutable = ( @@ -578,7 +581,7 @@ def _suggestions( allowed=allowed, deadline=monotonic() + 10, fingerprint_match=fingerprint_match, - )[0] + ) def _validate_operational_artifact( @@ -631,7 +634,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: return False return True - candidates = _suggestions( + candidates, complete = _suggestions( token, "", profiles, @@ -641,7 +644,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: ) if len(candidates) != 1: if isinstance(error.__cause__, CodexError): - raise InputError(NO_CORRECTION) from error + raise InputError(_no_correction_message(candidates, complete)) from error raise if not _confirm_correction(candidates[0], accepted, basis, fingerprint): raise CorrectionDeclined @@ -699,8 +702,8 @@ def allowed(candidate: CorrectionCandidate) -> bool: try: artifact = _parse(complete_value, profiles) except InputError as error: - candidates = ( - () + candidates, complete = ( + ((), True) if one else _suggestions( complete_value, @@ -724,7 +727,11 @@ def allowed(candidate: CorrectionCandidate) -> bool: if confirmation is True: artifact = candidates[0].artifact else: - message = NO_CORRECTION if not one and isinstance(error.__cause__, CodexError) else str(error) + message = ( + _no_correction_message(candidates, complete) + if not one and isinstance(error.__cause__, CodexError) + else str(error) + ) _stderr(f"Rejected: {message}\n") prefill = _retry_text(entered, prefix) prefix = _prefix_for_suffix(prefix, prefill) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 8b27eb87..7a8f79b1 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -47,6 +47,7 @@ from codex32.correction import ( CorrectionCandidate, _best, + _no_correction_message, _residue_low_discrimination, correct_worksheet_residue, ) @@ -261,7 +262,7 @@ def _creation_source( except CodexError as error: if not sys.stdin.isatty(): raise _UsageError(str(error)) from error - candidates = _suggestions( + candidates, complete = _suggestions( value, "", (profile,), @@ -281,10 +282,7 @@ def _creation_source( _print("", err=True) prefill = value continue - _print( - "Rejected: No unique correction found. Check your codex32 backup and re-enter it carefully.", - err=True, - ) + _print(f"Rejected: {_no_correction_message(candidates, complete)}", err=True) def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResult] | None = None) -> None: @@ -637,14 +635,10 @@ def _correct( complete = True else: candidates, complete = _scheduled_candidates(search_value, erased, hrp, byte_length, immutable) - if not complete and not candidates: - raise _CommandError("The correction search did not complete within ten seconds.") - if not candidates: - raise _CommandError("No valid correction found. Check the original backup.") if core is not None and len(candidates) > 1: candidates = _best(candidates, fingerprint_match=_fingerprint_matcher(core.fingerprint)) if len(candidates) != 1: - raise _CommandError("Several corrections are possible. Check the original backup.") + raise _CommandError(_no_correction_message(candidates, complete)) fixed = candidates[0] _require_correction_confirmation(fixed.low_checksum_discrimination) warning = ( diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 39fc2627..ff331409 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -111,6 +111,17 @@ def _low_discrimination(volume: int, bits: int) -> bool: return 32 * volume > 1 << bits +def _no_correction_message(candidates: Sequence[CorrectionCandidate], complete: bool) -> str: + """Say why no correction is shown, never how to edit the entry.""" + if len(candidates) > 1: + reason = "Several corrections are possible." + elif not complete: + reason = "The correction search ran out of time." + else: + reason = "No correction was found." + return f"{reason} Check your backup and re-enter it carefully." + + def _capture_mass(layers: Sequence[tuple[int, int]], rank: int) -> tuple[int, int]: """Normalize all admitted equal-or-better classes, including unsearched work.""" included = tuple((volume, bits) for volume, bits in layers if volume <= rank) diff --git a/tests/test_cli.py b/tests/test_cli.py index 003c77e2..eeb3f7df 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -620,7 +620,7 @@ def test_embedded_correction_schedules_both_mixed_case_interpretations_before_al source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" damaged = "ms10testsxPxxxxxPxxxxxPxxxxxPxxxxxP4nzvca9cmczlw" - candidates = input_module._suggestions(damaged, "", (Profile.MS,), []) + candidates, _complete = input_module._suggestions(damaged, "", (Profile.MS,), []) assert len(candidates) == 1 assert candidates[0].artifact.text == source @@ -660,9 +660,7 @@ def test_xprv_rejects_unreadable_entries_without_edit_hints(monkeypatch, capsys) assert ms_main(["xprv"]) == 0 rejected = [line for line in capsys.readouterr().err.splitlines() if line.startswith("Rejected:")] # A hint about what is wrong invites a guess, and a wrong guess can make a wrong correction findable. - assert ( - rejected == ["Rejected: No unique correction found. Check your backup and re-enter it carefully."] * 2 - ) + assert rejected == ["Rejected: No correction was found. Check your backup and re-enter it carefully."] * 2 def test_xprv_rejects_every_uncorrectable_prefixed_ms_length(monkeypatch, capsys) -> None: @@ -678,7 +676,7 @@ def test_xprv_rejects_every_uncorrectable_prefixed_ms_length(monkeypatch, capsys assert ms_main(["xprv"]) == 0 output = capsys.readouterr().err assert output.count( - "Rejected: No unique correction found. Check your backup and re-enter it carefully." + "Rejected: No correction was found. Check your backup and re-enter it carefully." ) == len(lengths) assert "characters" not in output @@ -692,7 +690,7 @@ def test_prefixed_ms_lengths_at_correction_boundary_still_search(monkeypatch, ca def suggestions(value, *_args, **_kwargs): searched.append(value) - return () + return (), True monkeypatch.setattr(input_module.sys, "stdin", _TTYInput()) monkeypatch.setattr(input_module, "_line_editor", None) @@ -703,7 +701,7 @@ def suggestions(value, *_args, **_kwargs): output = capsys.readouterr().err assert [len(value) for value in searched] == list(lengths) assert output.count( - "Rejected: No unique correction found. Check your backup and re-enter it carefully." + "Rejected: No correction was found. Check your backup and re-enter it carefully." ) == len(lengths) @@ -714,35 +712,55 @@ def test_complete_paste_with_bad_threshold_gets_no_hint(monkeypatch, capsys) -> monkeypatch.setattr(input_module.sys, "stdin", _TTYInput()) monkeypatch.setattr(input_module, "_line_editor", None) monkeypatch.setattr(input_module, "_editable_input", lambda _prompt, _prefill="": next(answers)) - monkeypatch.setattr(input_module, "_suggestions", lambda *_args, **_kwargs: ()) + monkeypatch.setattr(input_module, "_suggestions", lambda *_args, **_kwargs: ((), True)) assert ms_main(["xprv"]) == 0 output = capsys.readouterr().err - assert "Rejected: No unique correction found. Check your backup and re-enter it carefully." in output + assert "Rejected: No correction was found. Check your backup and re-enter it carefully." in output assert "threshold" not in output def test_redirected_uncorrectable_input_gets_no_hint(monkeypatch) -> None: input_module = importlib.import_module("codex32._cli_input") - monkeypatch.setattr(input_module, "_suggestions", lambda *_args, **_kwargs: ()) + monkeypatch.setattr(input_module, "_suggestions", lambda *_args, **_kwargs: ((), True)) result = _invoke(["secret"], VECTOR_1["secret_s"][:-1] + "b") assert result.exit_code != 0 - assert "No unique correction found. Check your backup and re-enter it carefully." in result.stderr + assert "No correction was found. Check your backup and re-enter it carefully." in result.stderr assert "character" not in result.stderr +@pytest.mark.parametrize( + ("result", "reason"), + ( + (((), True), "No correction was found."), + (((), False), "The correction search ran out of time."), + (((object(), object()), True), "Several corrections are possible."), + ), +) +def test_redirected_rejection_says_why_no_correction_is_shown(monkeypatch, result, reason) -> None: + input_module = importlib.import_module("codex32._cli_input") + monkeypatch.setattr(input_module, "_suggestions", lambda *_args, **_kwargs: result) + + rejected = _invoke(["secret"], VECTOR_1["secret_s"][:-1] + "b") + + assert rejected.exit_code != 0 + # Say what happened, never how to edit the entry. + assert f"{reason} Check your backup and re-enter it carefully." in rejected.stderr + assert "character" not in rejected.stderr + + def test_existing_seed_without_unique_correction_gets_no_hint(monkeypatch, capsys) -> None: cli_module = importlib.import_module("codex32.cli") answers = iter((VECTOR_1["secret_s"][:-1] + "b", "00" * 16)) monkeypatch.setattr(cli_module.sys, "stdin", _TTYInput()) monkeypatch.setattr(cli_module, "_text", lambda *_args, **_kwargs: next(answers)) - monkeypatch.setattr(cli_module, "_suggestions", lambda *_args, **_kwargs: ()) + monkeypatch.setattr(cli_module, "_suggestions", lambda *_args, **_kwargs: ((), True)) assert cli_module._creation_source(Profile.MS) == bytes(16) assert capsys.readouterr().err == ( - "Rejected: No unique correction found. Check your codex32 backup and re-enter it carefully.\n" + "Rejected: No correction was found. Check your backup and re-enter it carefully.\n" ) @@ -768,7 +786,7 @@ def answer(*_args, **kwargs): assert seen_prefills == ["", ""] output = capsys.readouterr().err assert "Hexadecimal seeds must contain 16, 20, 24, 28, 32, or 64 bytes" in output - assert "No unique correction" not in output + assert "correction" not in output @pytest.mark.parametrize(("difference", "word"), ((-9, "short of"), (9, "too long for"))) @@ -788,7 +806,7 @@ def test_subsequent_prefixed_ms_input_with_wrong_length_gets_no_hint( assert main(["secret", "--plain"]) == 0 output = capsys.readouterr() assert output.out.strip() == VECTOR_2["secret_S"] - assert "Rejected: No unique correction found. Check your backup and re-enter it carefully." in output.err + assert "Rejected: No correction was found. Check your backup and re-enter it carefully." in output.err assert word not in output.err @@ -1847,7 +1865,7 @@ def test_cli_rejects_statistically_inadmissible_structural_burst() -> None: result = _invoke(["correct"], damaged) assert result.exit_code == 3 - assert "No valid correction found" in result.stderr + assert "No correction was found" in result.stderr def test_cli_rejects_sixteen_consecutive_erasures_as_outside_regular_bound() -> None: @@ -1858,7 +1876,7 @@ def test_cli_rejects_sixteen_consecutive_erasures_as_outside_regular_bound() -> assert len("".join(damaged.split())) == 48 assert damaged.count("?") == 16 assert result.exit_code == 3 - assert "No valid correction found" in result.stderr + assert "No correction was found" in result.stderr def test_correct_rejects_malformed_immutable_hrp_as_usage() -> None: @@ -1910,7 +1928,7 @@ def test_cli_never_accepts_an_incomplete_structural_search() -> None: assert result.exit_code == 3 assert result.stdout == "" - assert "did not complete" in result.stderr and original not in result.stderr + assert "ran out of time" in result.stderr and original not in result.stderr @pytest.mark.parametrize( @@ -1997,7 +2015,7 @@ def test_correct_suggests_the_majority_case_for_mixed_case_damage() -> None: assert result.exit_code == 1 assert source in result.stderr - assert "No valid correction found" not in result.stderr + assert "No correction was found" not in result.stderr assert wrong_length.exit_code == 2 assert "--bytes does not match" in wrong_length.stderr assert source not in wrong_length.stderr @@ -2069,7 +2087,7 @@ def test_correct_required_work_is_not_starved_by_erasure_alignment() -> None: assert result.exit_code == 1 assert source in result.stderr - assert "did not complete within ten seconds" not in result.stderr + assert "ran out of time" not in result.stderr assert "interactive confirmation required" not in result.stderr @@ -2118,8 +2136,8 @@ def test_correction_hides_internal_candidate_reparse_failures() -> None: assert result.exit_code == 3 assert result.stdout == "" assert result.stderr.strip() in { - "codex32 correct: No valid correction found. Check the original backup.", - "codex32 correct: The correction search did not complete within ten seconds.", + "codex32 correct: No correction was found. Check your backup and re-enter it carefully.", + "codex32 correct: The correction search ran out of time. Check your backup and re-enter it carefully.", } assert "threshold" not in result.stderr @@ -2876,7 +2894,7 @@ def test_creation_source_unusable_candidates_retry(monkeypatch, capsys, kind): artifact = parse_codex32(VECTOR_6["codex32_peev"]) candidate = SimpleNamespace(artifact=artifact, search_complete=True, low_checksum_discrimination=False) candidates = () if kind == "none" else (candidate, candidate) if kind == "ambiguous" else (candidate,) - monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: candidates) + monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: (candidates, True)) answers = iter(("ms1invalid", "00" * 16)) monkeypatch.setattr(sys, "stdin", _TTYInput()) monkeypatch.setattr(module, "_editable_input", lambda *args: next(answers)) @@ -2903,7 +2921,7 @@ def test_creation_source_does_not_label_an_incomplete_share_candidate(monkeypatc def suggestions(*args, **kwargs): assert not kwargs["allowed"](candidate) - return (candidate,) + return (candidate,), True monkeypatch.setattr(sys, "stdin", _TTYInput()) monkeypatch.setattr(module, "_editable_input", lambda *args: next(answers)) @@ -2930,7 +2948,7 @@ def answer(*args): monkeypatch.setattr(sys, "stdin", _TTYInput()) monkeypatch.setattr(module, "_editable_input", answer) - monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: ()) + monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: ((), True)) with pytest.raises(exception): cli._creation_source(Profile.MS) @@ -2981,7 +2999,8 @@ def test_corrected_creation_source_identity_and_acceptance_boundary(monkeypatch) cli, "_suggestions", lambda *args, **kwargs: ( - SimpleNamespace(artifact=secret, search_complete=True, low_checksum_discrimination=False), + (SimpleNamespace(artifact=secret, search_complete=True, low_checksum_discrimination=False),), + True, ), ) answers = iter(("ms1invalid", "n", "ms1invalid", "yes", "", secret.text)) diff --git a/tests/test_correction_disclosure.py b/tests/test_correction_disclosure.py index 55836f2b..e1a1c5d4 100644 --- a/tests/test_correction_disclosure.py +++ b/tests/test_correction_disclosure.py @@ -134,8 +134,8 @@ def respond(prompt, prefill=""): return answer monkeypatch.setattr(_cli_input, "_editable_input", respond) - monkeypatch.setattr(_cli_input, "_suggestions", lambda *args, **kwargs: (candidate,)) - monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: (candidate,)) + monkeypatch.setattr(_cli_input, "_suggestions", lambda *args, **kwargs: ((candidate,), True)) + monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: ((candidate,), True)) monkeypatch.setattr( _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None) ) @@ -253,7 +253,7 @@ def confirm(prompt): return next(answers) monkeypatch.setattr(_cli_input, "_confirmation_input", confirm) - monkeypatch.setattr(_cli_input, "_suggestions", lambda *args, **kwargs: (candidate,)) + monkeypatch.setattr(_cli_input, "_suggestions", lambda *args, **kwargs: ((candidate,), True)) stdout, stderr = io.StringIO(), _TTYOutput() with ( patch.object(sys, "stdin", io.StringIO(source[:-1] + "?")), @@ -272,7 +272,7 @@ def confirm(prompt): def test_redirected_recovery_without_terminal_reveals_nothing(monkeypatch): source = VECTOR_1["secret_s"] candidate = _candidate(source) - monkeypatch.setattr(_cli_input, "_suggestions", lambda *args, **kwargs: (candidate,)) + monkeypatch.setattr(_cli_input, "_suggestions", lambda *args, **kwargs: ((candidate,), True)) stdout, stderr = io.StringIO(), io.StringIO() with ( patch.object(sys, "stdin", io.StringIO(source[:-1] + "?")),