From 4950f07d81277344d766a155949ba520d7fab670 Mon Sep 17 00:00:00 2001 From: n1ckyb Date: Sun, 9 Aug 2026 20:39:28 +0100 Subject: [PATCH] fix(cli): add the command people type first, and stop crashing on fallback Four defects found by using the CLI the way a new user would. **`intentumdiff diff` did not exist.** It is the first thing anyone types, and it failed with "No such command 'diff'. Did you mean 'gist-diff'?" - a suggestion pointing away from the two commands that actually diff things. Now aliased to `file`. This needed registering in BOTH layers. The entry point is a Click group in _parser.py that registers commands by name and delegates to argparse; the Click group rejects unknown commands before argparse is ever consulted, so aliases=["diff"] on the argparse subparser alone changed nothing. **`--json` was not accepted**, failing with "unrecognized arguments: --json" even though JSON output existed the whole time behind `--format json`. An error that denies a feature we actually ship is worse than a missing feature, because it teaches the user the tool cannot do it. Added as a store_const alias. **The fallback path crashed while rendering its own output.** The token-level fallback description contained U+2192; a Windows console is cp1252, so Rich raised UnicodeEncodeError and printed "Error: 'charmap' codec can't encode character" INSIDE the results table - on exactly the files that had already failed to parse. Same for an em dash in the accompanying log line. Both ASCII now. **A retired distribution now warns instead of failing silently.** Both `intentdiff` (pre-rebrand) and `intentumdiff-python` install the same `intentumdiff` import package, so with both present the resolved distribution name can be the retired one - which is not in the first-party trust allowlist. Every bundled parser is then rejected as untrusted and the user sees a stream of `native_fallback: parse errors require Python token fallback` and no diff at all. Nothing in that chain names the cause, and the failure is total rather than partial, so it reads as "the tool is broken". Same defect class as 0.0.1. Verified in the source repo: `diff` returns a change, `--json` emits JSON, the fallback table renders without the crash, and stderr is 0 bytes on a clean run. pyproject.toml is deliberately NOT synced - the version already matches (0.0.2b1) and this branch's comment wording differs from the monorepo's. Co-Authored-By: Claude Opus 5 --- src/intentumdiff/_differ_presentation.py | 7 ++++- src/intentumdiff/cli/__init__.py | 33 ++++++++++++++++++++++++ src/intentumdiff/cli/_commands.py | 17 +++++++++++- src/intentumdiff/cli/_parser.py | 12 ++++++++- src/intentumdiff/differ.py | 2 +- 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/intentumdiff/_differ_presentation.py b/src/intentumdiff/_differ_presentation.py index 16c2630..1828add 100644 --- a/src/intentumdiff/_differ_presentation.py +++ b/src/intentumdiff/_differ_presentation.py @@ -486,7 +486,12 @@ def _token_fallback_diff( changes.append( Change( change_type=ChangeType.MODIFICATION, - description=f"token-level fallback: {i2 - i1} token(s) → {j2 - j1} token(s)", + # ASCII arrow deliberately. A Windows console defaults to cp1252, which + # cannot encode U+2192, and Rich raised UnicodeEncodeError while rendering + # the row — so the fallback path printed "Error: 'charmap' codec can't + # encode character" INSIDE the results table, on exactly the files that had + # already failed to parse. + description=f"token-level fallback: {i2 - i1} token(s) -> {j2 - j1} token(s)", confidence=0.5, ) ) diff --git a/src/intentumdiff/cli/__init__.py b/src/intentumdiff/cli/__init__.py index 01f9ab9..0a2d3ed 100644 --- a/src/intentumdiff/cli/__init__.py +++ b/src/intentumdiff/cli/__init__.py @@ -191,7 +191,40 @@ ] +#: The pre-rebrand distribution. Installing IntentumDiff does not remove it, because pip +#: treats a renamed project as an unrelated package. +_RETIRED_DISTRIBUTION = "intentdiff" + + +def _warn_if_retired_distribution_installed() -> None: + """ + Warn when the pre-rebrand ``intentdiff`` distribution is installed alongside this one. + + Both projects install the same ``intentumdiff`` import package, so whichever pip laid down + last wins and the resolved distribution name may be the retired one — which is NOT in the + first-party trust allowlist. Every bundled parser is then rejected as untrusted third-party + code, and the user sees a stream of ``native_fallback`` errors and no diff at all. + + Nothing in that chain names the actual cause, and the failure is total rather than partial, + so it reads as "the tool is broken" rather than "you have two installs". This is the same + defect class that made 0.0.1 unusable: a package failing its own trust check because of how + its distribution name resolves. + """ + try: + from importlib.metadata import distribution + + distribution(_RETIRED_DISTRIBUTION) + except Exception: # noqa: BLE001 - absence is the normal case, and any lookup failure is fine + return + _err.print( + f"[yellow]Warning:[/yellow] the retired '{_RETIRED_DISTRIBUTION}' distribution is " + "installed alongside IntentumDiff. They share an import package, so parsers may be " + f"rejected as untrusted and diffs may fail. Run: pip uninstall {_RETIRED_DISTRIBUTION}" + ) + + def main(argv: list[str] | None = None) -> NoReturn: + _warn_if_retired_distribution_installed() _legacy_click_main(argv) diff --git a/src/intentumdiff/cli/_commands.py b/src/intentumdiff/cli/_commands.py index cfd55af..5f46924 100644 --- a/src/intentumdiff/cli/_commands.py +++ b/src/intentumdiff/cli/_commands.py @@ -454,7 +454,11 @@ def _cmd_plugins_list(_args: argparse.Namespace) -> None: # Resolve the core package provenance string once for built-in rows import importlib.metadata as _meta - for _dist_name in ("intentumdiff",): + # The DISTRIBUTION name, which is not the import name: this package publishes as + # `intentumdiff-python` while `import intentumdiff` is the package. Looking up only the + # import name silently fell through to the bare "IntentumDiff" fallback, so every + # built-in plugin row lost its version. + for _dist_name in ("intentumdiff-python", "intentumdiff_python", "intentumdiff"): try: _core_dist = _meta.distribution(_dist_name) _core_prov = ( @@ -1939,6 +1943,17 @@ def _add_output_args(p: argparse.ArgumentParser) -> None: metavar="FORMAT", help="Output format: terminal (default), json, patch, html, llm", ) + # `--json` is the spelling people reach for first, and reaching for it used to fail with + # "unrecognized arguments: --json" even though JSON output existed the whole time behind + # `--format json`. An error that denies a feature you actually ship is worse than a missing + # feature, because it teaches the user the tool cannot do it. + p.add_argument( + "--json", + dest="format", + action="store_const", + const="json", + help="Shorthand for --format json.", + ) p.add_argument( "--output", "-o", metavar="FILE", diff --git a/src/intentumdiff/cli/_parser.py b/src/intentumdiff/cli/_parser.py index 9eab40b..7658a47 100644 --- a/src/intentumdiff/cli/_parser.py +++ b/src/intentumdiff/cli/_parser.py @@ -222,7 +222,13 @@ def _build_parser() -> argparse.ArgumentParser: assets_git_p.set_defaults(func=_cmd_assets_git) # ── file ───────────────────────────────────────────────────────────────── - file_p = sub.add_parser("file", help="Diff two local files.") + # `diff` is the first thing anyone types, and it used to fail with + # "No such command 'diff'. Did you mean 'gist-diff'?" — a suggestion that points away from + # the two commands that actually diff things. Aliasing it to `file` costs nothing and + # removes a dead end from the very first interaction with the tool. + file_p = sub.add_parser( + "file", aliases=["diff"], help="Diff two local files (alias: diff)." + ) file_p.add_argument("old_file", metavar="OLD", help="Path to the old file.") file_p.add_argument("new_file", metavar="NEW", help="Path to the new file.") _add_output_args(file_p) @@ -995,6 +1001,10 @@ def _click_cli(ctx: click.Context, no_banner: bool) -> None: ("git", "Diff files or commits in a git repository."), ("assets", "Perceptual diffs for non-text assets."), ("file", "Diff two local files."), + # Registered here as well as aliased on the argparse parser: this Click group is what + # rejects unknown commands, so without an entry here `diff` still fails before argparse + # is ever consulted — and fails suggesting `gist-diff`. + ("diff", "Diff two local files (alias for 'file')."), ("patch", "Diff from a unified diff patch."), ("string", "Diff two in-memory strings."), ("github-pr", "Parse a GitHub pull request URL into a review target."), diff --git a/src/intentumdiff/differ.py b/src/intentumdiff/differ.py index ed1a1b9..2826a16 100644 --- a/src/intentumdiff/differ.py +++ b/src/intentumdiff/differ.py @@ -1668,7 +1668,7 @@ def _run_stages_1_to_11( _has_error_node(old_tree) or _has_error_node(new_tree) ): logger.warning( - "Parse errors detected in %r — falling back to token-level diff", + "Parse errors detected in %r - falling back to token-level diff", filename, ) _raise_rust_only_gate_error("parse errors require Rust token-level fallback")