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")