diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32e0a3e..f1524e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,23 +64,23 @@ jobs: - target: linux-x64 runner: ubuntu-latest asset: wild-linux-x64 - smoke: ./release/wild-linux-x64 --help + smoke: python scripts/release_smoke.py ./release/wild-linux-x64 - target: linux-arm64 runner: ubuntu-24.04-arm asset: wild-linux-arm64 - smoke: ./release/wild-linux-arm64 --help + smoke: python scripts/release_smoke.py ./release/wild-linux-arm64 - target: macos-x64 runner: macos-15-intel asset: wild-macos-x64 - smoke: ./release/wild-macos-x64 --help + smoke: python scripts/release_smoke.py ./release/wild-macos-x64 - target: macos-arm64 runner: macos-15 asset: wild-macos-arm64 - smoke: ./release/wild-macos-arm64 --help + smoke: python scripts/release_smoke.py ./release/wild-macos-arm64 - target: windows-x64 runner: windows-latest asset: wild-win.exe - smoke: ./release/wild-win.exe --help + smoke: python scripts/release_smoke.py ./release/wild-win.exe runs-on: ${{ matrix.runner }} steps: @@ -106,6 +106,8 @@ jobs: else mv release/wild "release/${{ matrix.asset }}" fi + # A real diff (JSON, HTML, terminal), not just --help: --help cannot + # catch missing runtime data such as the DiffGraph schema. - name: Smoke test native executable shell: bash run: ${{ matrix.smoke }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 372de38..adf3522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.1] - 2026-11-05 + +### Fixed +- Native release binaries can run `wild diff` again. The cli-v1.3.0 binaries + were built without the packaged DiffGraph schema, so every canonical diff + (terminal, HTML, JSON) failed with "could not load canonical DiffGraph + schema". The build now bundles an explicit allowlist of runtime package + data (only the schema); `.env` files are still refused. +- Non-diff commands pass through to git verbatim again. `wild log + --format=…`, `wild show --format=…`, and `wild format-patch -o DIR` failed + because wild parsed its own `--format`/`--output` options for every + command. +- Release smoke tests are an end-to-end scenario suite instead of `--help`: + the exact commands the VS Code extension runs (health probe, working tree, + staged, history, including a parentless first commit), `REF`, `--staged + REF`, three-dot, pathspec, binary file, no-change, usage and runtime exit + codes, and git passthrough. Every JSON artifact is schema-validated. All + five release platforms run it on the native binary, and CI runs it on the + installed CLI. + ## [1.3.0] - 2026-10-26 ### Added diff --git a/build.py b/build.py index 7ea80e5..69951c8 100644 --- a/build.py +++ b/build.py @@ -1,14 +1,17 @@ #!/usr/bin/env python3 """Build a native, single-file ``wild`` executable with PyInstaller. -This builder deliberately never supplies PyInstaller with ``--add-data``. In -particular, a local ``.env`` is rejected before the build starts so credentials -cannot accidentally become part of a release executable. +The only data bundled is ``PACKAGED_DATA_FILES``: an explicit allowlist of +exact package files the CLI loads at runtime (the DiffGraph schema every +canonical artifact is validated against). Nothing else is ever passed to +PyInstaller as data, and a local ``.env`` is rejected before the build starts +so credentials cannot become part of a release executable. """ from __future__ import annotations import argparse +import os import shutil import subprocess import sys @@ -18,6 +21,11 @@ ROOT = Path(__file__).resolve().parent ENTRYPOINT = ROOT / "diffgraph" / "cli.py" FORBIDDEN_BUNDLE_FILE_NAMES = frozenset({".env"}) +# Runtime data loaded through importlib.resources. A missing entry here breaks +# every `wild diff` in the native binary (cli-v1.3.0 shipped without it). +PACKAGED_DATA_FILES = ( + ROOT / "diffgraph" / "schema" / "diffgraph-v2.schema.json", +) def find_forbidden_files(root: Path) -> list[Path]: @@ -44,8 +52,23 @@ def assert_release_workspace_safe(root: Path) -> None: ) +def packaged_data_arguments() -> list[str]: + """``--add-data`` arguments for exactly the allowlisted package files.""" + arguments: list[str] = [] + package_root = (ROOT / "diffgraph").resolve() + for listed in PACKAGED_DATA_FILES: + # Resolve symlinks first: a link inside the package must not pull an + # outside file into the release binary. + path = listed.resolve() + if not path.is_file() or package_root not in path.parents: + raise SystemExit(f"packaged data file is missing or outside the package: {listed}") + destination = path.parent.relative_to(ROOT.resolve()).as_posix() + arguments += ["--add-data", f"{path}{os.pathsep}{destination}"] + return arguments + + def build_command(*, output_dir: Path, work_dir: Path, spec_dir: Path, name: str) -> list[str]: - """Return the intentionally data-free native PyInstaller invocation.""" + """Return the native PyInstaller invocation; data is only the allowlist.""" return [ sys.executable, "-m", @@ -62,6 +85,7 @@ def build_command(*, output_dir: Path, work_dir: Path, spec_dir: Path, name: str str(work_dir), "--specpath", str(spec_dir), + *packaged_data_arguments(), str(ENTRYPOINT), ] diff --git a/diffgraph/__init__.py b/diffgraph/__init__.py index 42c16bb..844b76b 100644 --- a/diffgraph/__init__.py +++ b/diffgraph/__init__.py @@ -2,4 +2,4 @@ DiffGraph - A CLI tool for visualizing code changes with AI """ -__version__ = "1.3.0" +__version__ = "1.3.1" diff --git a/diffgraph/cli.py b/diffgraph/cli.py index fd766db..8f5562c 100644 --- a/diffgraph/cli.py +++ b/diffgraph/cli.py @@ -122,7 +122,38 @@ class _RawArgsCommand(click.Command): def parse_args(self, ctx, args): ctx.meta["raw_args"] = tuple(args) - return super().parse_args(ctx, args) + command_index = self._command_index(ctx, args) + if command_index is None or args[command_index] == "diff": + return super().parse_args(ctx, args) + # Every other command belongs to git verbatim: `wild log --format=%h` + # or `wild format-patch -o dir` must not be parsed as wild's options. + remaining = super().parse_args(ctx, list(args[:command_index])) + ctx.params["args"] = tuple(args[command_index:]) + return remaining + + def _command_index(self, ctx, args): + """Index of the first argument after wild's own leading options.""" + takes_value = set() + known = set(self.get_help_option_names(ctx)) + for param in self.params: + if isinstance(param, click.Option): + names = [*param.opts, *param.secondary_opts] + known.update(names) + if not param.is_flag and not param.count: + takes_value.update(names) + index = 0 + while index < len(args): + argument = args[index] + if argument == "--": + return None + name = argument.split("=", 1)[0] + if argument.startswith("-") and name in known: + index += 2 if name in takes_value and "=" not in argument else 1 + continue + # The command, or a git global option such as `-C DIR`, `-c k=v`, + # or `--no-pager`: everything from here belongs to git. + return index + return None def _terminal_options( diff --git a/scripts/release_smoke.py b/scripts/release_smoke.py new file mode 100644 index 0000000..8c276af --- /dev/null +++ b/scripts/release_smoke.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +"""End-to-end scenario suite for a ``wild`` executable (native or installed). + +Usage: python scripts/release_smoke.py + +Runs every prominent use case against a throwaway repository: the exact +commands the VS Code extension issues (health probe, working tree, staged, and +history views), the core CLI forms, and the error/exit-code contract. Every +JSON artifact is validated against the packaged DiffGraph schema when this +checkout's dependencies are importable (they are in CI), and structurally +otherwise. All scenarios run; failures are listed together. + +``--help`` alone cannot catch a binary that is missing runtime data: the +cli-v1.3.0 binaries could not load their DiffGraph schema. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Callable, List, Optional + +REPO_ROOT = Path(__file__).resolve().parents[1] +try: # Full schema validation with the checkout's own contract. + sys.path.insert(0, str(REPO_ROOT)) + from diffgraph.contract import validate_artifact as _schema_validate +except Exception: # pragma: no cover - stdlib-only environments + _schema_validate = None + + +class ScenarioFailure(Exception): + pass + + +def check(condition: bool, message: str) -> None: + if not condition: + raise ScenarioFailure(message) + + +class Workspace: + """A repository with history, a branch, and staged/unstaged/untracked work.""" + + def __init__(self, root: Path, executable: str) -> None: + self.root = root + self.repo = root / "repo" + self.executable = executable + home = root / "home" + home.mkdir() + # Git variables inherited from a hook (GIT_DIR, GIT_WORK_TREE, + # GIT_INDEX_FILE, ...) would point every command at another repository. + inherited = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + self.env = { + **inherited, + # No user config, relay token, or .env can influence the run. + "HOME": str(home), + "USERPROFILE": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "Release Smoke", + "GIT_AUTHOR_EMAIL": "smoke@example.invalid", + "GIT_COMMITTER_NAME": "Release Smoke", + "GIT_COMMITTER_EMAIL": "smoke@example.invalid", + } + for key in ("BYOK_RELAY_TOKEN", "BYOK_RELAY_URL", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"): + self.env.pop(key, None) + self._build() + + def git(self, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=self.repo, env=self.env, check=True, capture_output=True, text=True + ).stdout.strip() + + def write(self, path: str, text: str) -> None: + target = self.repo / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + + def _build(self) -> None: + self.repo.mkdir() + self.git("init", "-q") + self.git("checkout", "-q", "-b", "main") + self.write("pkg/__init__.py", "") + self.write("pkg/lib.py", "class Base:\n pass\n\ndef f(a):\n return a\n") + self.write("pkg/use.py", "from pkg.lib import Base, f\n\nclass Child(Base):\n def run(self):\n return f(1)\n") + self.write("README.md", "# Smoke\n") + (self.repo / "data.bin").write_bytes(b"\x00\x01binary\x00") + self.git("add", ".") + self.git("commit", "-q", "-m", "root") + self.root_commit = self.git("rev-parse", "HEAD") + # A feature branch from the root, for three-dot comparisons. + self.git("checkout", "-q", "-b", "feature") + self.write("pkg/feature.py", "from pkg.lib import f\n\ndef feature():\n return f(3)\n") + self.git("add", ".") + self.git("commit", "-q", "-m", "feature") + self.git("checkout", "-q", "main") + # Change a signature and a base class; pkg/use.py (untouched) calls/subclasses them. + self.write("pkg/lib.py", "class Base:\n x = 1\n\ndef f(a, b=0):\n return a + b\n") + self.git("commit", "-q", "-am", "change signature") + self.change_commit = self.git("rev-parse", "HEAD") + # Working tree: staged README, unstaged use.py and data.bin, untracked new.py. + self.write("README.md", "# Smoke\n\nStaged.\n") + self.git("add", "README.md") + self.write("pkg/use.py", "from pkg.lib import Base, f\n\nclass Child(Base):\n def run(self):\n return f(2)\n") + (self.repo / "data.bin").write_bytes(b"\x00\x02binary\x00") + self.write("new.py", "def fresh():\n return 0\n") + + def wild(self, *args: str, expect: int = 0) -> subprocess.CompletedProcess: + result = subprocess.run( + [self.executable, *args], cwd=self.repo, env=self.env, capture_output=True, text=True, + encoding="utf-8", errors="replace", + ) + if result.returncode != expect: + tail = "\n".join((result.stderr or result.stdout).strip().splitlines()[-5:]) + raise ScenarioFailure(f"`wild {' '.join(args)}` exited {result.returncode}, expected {expect}:\n{tail}") + return result + + def out(self, name: str) -> Path: + return self.root / name + + def json_file(self, *args: str, name: str) -> dict: + path = self.out(name) + self.wild(*args, "--output", str(path)) + return validated(json.loads(path.read_text(encoding="utf-8")), " ".join(args)) + + def html_file(self, *args: str, name: str) -> str: + path = self.out(name) + self.wild(*args, "--output", str(path)) + report = path.read_text(encoding="utf-8") + check(report.lower().startswith(""), f"{name}: not an HTML document") + check('id="diffgraph-artifact"' in report and "Content-Security-Policy" in report, + f"{name}: missing the embedded artifact or CSP") + return report + + +def validated(artifact: dict, label: str) -> dict: + check(re.fullmatch(r"2\.\d+", str(artifact.get("schema_version"))) is not None, + f"{label}: schema_version {artifact.get('schema_version')!r}") + for key in ("files", "symbols", "relationships", "metadata", "diff_ref"): + check(key in artifact, f"{label}: missing {key}") + if _schema_validate is not None: + try: + _schema_validate(artifact) + except Exception as error: # noqa: BLE001 - report any contract failure + raise ScenarioFailure(f"{label}: schema validation failed: {error}") from error + return artifact + + +def paths(artifact: dict, *, changed_only: bool = True) -> set: + return {item["path"] for item in artifact["files"] + if not changed_only or item["change_kind"] != "unchanged"} + + +def warning_codes(artifact: dict) -> set: + return {item["code"] for item in artifact["metadata"].get("warnings", [])} + + +# -------------------------------------------------------------------------- +# Scenarios +# -------------------------------------------------------------------------- + +def vscode_version(ws: Workspace) -> str: + stdout = ws.wild("--version").stdout.strip() + check(re.fullmatch(r"wild, version \d+\.\d+\.\d+", stdout) is not None, f"unexpected version line {stdout!r}") + return stdout + + +def vscode_health_probe(ws: Workspace) -> str: + """CliService.probeRuntime: `wild diff --format json` on stdout.""" + artifact = validated(json.loads(ws.wild("diff", "--format", "json").stdout), "health probe") + check(artifact["diff_ref"]["kind"] == "unstaged", "probe artifact is not an unstaged diff") + check({"pkg/use.py", "new.py"} <= paths(artifact), f"probe files {sorted(paths(artifact))}") + return f"{len(artifact['files'])} files on stdout" + + +def vscode_working_tree_html(ws: Workspace) -> str: + path = ws.out("working.html") + ws.wild("diff", "--format", "html", "--output", str(path), "--no-open") + report = path.read_text(encoding="utf-8") + check(report.lower().startswith("") and 'id="diffgraph-artifact"' in report, "not a DiffGraph report") + return f"{len(report)} bytes" + + +def vscode_staged_html(ws: Workspace) -> str: + path = ws.out("staged.html") + ws.wild("diff", "--format", "html", "--output", str(path), "--no-open", "--staged") + report = path.read_text(encoding="utf-8") + check('id="diffgraph-artifact"' in report and "Staged changes" in report, "staged report missing its snapshot label") + return "ok" + + +def vscode_working_tree_json(ws: Workspace) -> str: + artifact = ws.json_file("diff", "--format", "json", name="working.json") + check({"pkg/use.py", "new.py", "data.bin"} <= paths(artifact), f"files {sorted(paths(artifact))}") + check("README.md" not in paths(artifact), "a staged-only file appeared in the unstaged view") + return f"{len(artifact['files'])} files" + + +def vscode_staged_json(ws: Workspace) -> str: + path = ws.out("staged.json") + ws.wild("diff", "--format", "json", "--output", str(path), "--staged") + artifact = validated(json.loads(path.read_text(encoding="utf-8")), "staged json") + check(artifact["diff_ref"]["kind"] == "staged", "not a staged diff") + check(paths(artifact) == {"README.md"}, f"staged files {sorted(paths(artifact))}") + check("UNSUPPORTED_LANGUAGE" in warning_codes(artifact), "no UNSUPPORTED_LANGUAGE warning for Markdown") + return "README.md only" + + +def vscode_history_commit(ws: Workspace) -> str: + """HistoryViewProvider: `~1..` as HTML and JSON.""" + commit = ws.change_commit + artifact = ws.json_file("diff", f"{commit}~1..{commit}", "--format", "json", name="history.json") + check(artifact["diff_ref"]["kind"] == "commit_range", "not a commit range") + check(paths(artifact) == {"pkg/lib.py"}, f"commit files {sorted(paths(artifact))}") + context = paths(artifact, changed_only=False) - paths(artifact) + check("pkg/use.py" in context, "the untouched caller pkg/use.py is not reported outside the diff") + kinds = {item["kind"] for item in artifact["relationships"]} + check({"calls", "inherits"} <= kinds, f"expected calls and inherits edges, got {sorted(kinds)}") + symbols = {item["name"]: item for item in artifact["symbols"]} + check(symbols["f"].get("source", {}).get("after", {}).get("text", "").startswith("def f(a, b=0)"), + "the changed symbol carries no exact after-source") + path = ws.out("history.html") + ws.wild("diff", f"{commit}~1..{commit}", "--format", "html", "--output", str(path), "--no-open") + check('id="diffgraph-artifact"' in path.read_text(encoding="utf-8"), "history HTML is not a DiffGraph report") + return "callers and subclasses outside the diff found" + + +def vscode_history_root_commit(ws: Workspace) -> str: + """The first commit has no parent: `~1..` must not crash.""" + commit = ws.root_commit + artifact = ws.json_file("diff", f"{commit}~1..{commit}", "--format", "json", name="root.json") + check(artifact["files"] == [], "a parentless range invented changes") + check("invalid_base_ref" in warning_codes(artifact), f"warnings {sorted(warning_codes(artifact))}") + return "exit 0 with an invalid_base_ref warning" + + +def cli_terminal_default(ws: Workspace) -> str: + stdout = ws.wild("diff").stdout + check("wild diff" in stdout and "pkg/use.py" in stdout, "terminal review path is missing the change") + ws.wild("diff", "--compact") + ws.wild("diff", "--all") + return "default, --compact, --all" + + +def cli_ref_against_working_tree(ws: Workspace) -> str: + artifact = ws.json_file("diff", "HEAD~1", "--format", "json", name="ref.json") + check(artifact["diff_ref"]["kind"] == "working_tree", f"kind {artifact['diff_ref']['kind']}") + check(artifact["diff_ref"]["requested_base_ref"] == "HEAD~1", "requested ref not recorded") + check({"pkg/lib.py", "pkg/use.py", "README.md", "new.py"} <= paths(artifact), f"files {sorted(paths(artifact))}") + return "wild diff HEAD~1" + + +def cli_staged_against_ref(ws: Workspace) -> str: + artifact = ws.json_file("diff", "--staged", "HEAD~1", "--format", "json", name="staged-ref.json") + check(artifact["diff_ref"]["kind"] == "staged" and artifact["diff_ref"]["requested_base_ref"] == "HEAD~1", + "not a staged diff against HEAD~1") + check(paths(artifact) == {"pkg/lib.py", "README.md"}, f"files {sorted(paths(artifact))}") + return "wild diff --staged HEAD~1" + + +def cli_three_dot_branch(ws: Workspace) -> str: + artifact = ws.json_file("diff", "main...feature", "--format", "json", name="three-dot.json") + check(artifact["diff_ref"]["comparison_mode"] == "three_dot", "not a three-dot comparison") + check(paths(artifact) == {"pkg/feature.py"}, f"files {sorted(paths(artifact))}") + return "main...feature" + + +def cli_pathspec(ws: Workspace) -> str: + # Options go before `--`; everything after it is a pathspec. + path = ws.out("pathspec.json") + ws.wild("diff", "--format", "json", "--output", str(path), "--", "pkg/use.py") + artifact = validated(json.loads(path.read_text(encoding="utf-8")), "pathspec") + check(paths(artifact) == {"pkg/use.py"}, f"files {sorted(paths(artifact))}") + return "-- pkg/use.py" + + +def cli_binary_file(ws: Workspace) -> str: + artifact = json.loads(ws.out("working.json").read_text(encoding="utf-8")) if ws.out("working.json").exists() \ + else ws.json_file("diff", "--format", "json", name="working.json") + check("PARTIAL_ANALYSIS" in warning_codes(artifact), "no PARTIAL_ANALYSIS warning for the binary file") + return "data.bin reported, not parsed" + + +def cli_no_changes(ws: Workspace) -> str: + artifact = json.loads(ws.wild("diff", "HEAD..HEAD", "--format", "json").stdout) + validated(artifact, "no changes") + check(artifact["files"] == [] and artifact["symbols"] == [], "an empty range reported changes") + return "exit 0, empty artifact" + + +def errors_usage(ws: Workspace) -> str: + unknown = ws.wild("diff", "nosuchref", expect=2) + check("unknown revision 'nosuchref'" in unknown.stderr, "unknown revision message missing") + ws.wild("diff", "--format", "terminal", "--output", str(ws.out("x.txt")), expect=2) + ws.wild("diff", "--llm", "claude:turbo", expect=2) + return "unknown revision, terminal+--output, bad --llm tier -> exit 2" + + +def errors_runtime(ws: Workspace) -> str: + target = ws.out("missing-dir") / "out.json" + ws.wild("diff", "--format", "json", "--output", str(target), expect=1) + check(not target.exists(), "a failed write left a file behind") + return "unwritable output -> exit 1, nothing written" + + +def git_passthrough(ws: Workspace) -> str: + status = ws.wild("status", "--short").stdout + check("new.py" in status, "`wild status` is not passed through to git") + # git's own options that share wild's names must reach git untouched. + log = ws.wild("log", "-1", "--format=%s").stdout.strip() + check(log == "change signature", f"`wild log --format=%s` returned {log!r}") + shown = ws.wild("show", "--no-patch", "--format=%an", "HEAD").stdout.strip() + check(shown == "Release Smoke", f"`wild show --format=%an` returned {shown!r}") + patch_dir = ws.out("patches") + ws.wild("format-patch", "-1", "-o", str(patch_dir), "--quiet") + check(any(patch_dir.glob("*.patch")), "`wild format-patch -o DIR` wrote no patch") + return "status, log --format, format-patch -o" + + +SCENARIOS: List[tuple] = [ + ("vscode: --version", vscode_version), + ("vscode: health probe (JSON on stdout)", vscode_health_probe), + ("vscode: working tree HTML", vscode_working_tree_html), + ("vscode: working tree JSON", vscode_working_tree_json), + ("vscode: staged HTML", vscode_staged_html), + ("vscode: staged JSON", vscode_staged_json), + ("vscode: history commit (~1..)", vscode_history_commit), + ("vscode: history root commit", vscode_history_root_commit), + ("cli: terminal review path", cli_terminal_default), + ("cli: REF -> working tree", cli_ref_against_working_tree), + ("cli: --staged REF", cli_staged_against_ref), + ("cli: three-dot branch comparison", cli_three_dot_branch), + ("cli: pathspec", cli_pathspec), + ("cli: binary file", cli_binary_file), + ("cli: no changes", cli_no_changes), + ("errors: usage (exit 2)", errors_usage), + ("errors: runtime (exit 1)", errors_runtime), + ("git passthrough", git_passthrough), +] + + +def main(binary: str, only: Optional[Callable] = None) -> int: + executable = str(Path(binary).resolve()) if Path(binary).exists() else binary + failures = [] + print(f"Scenario suite for {executable} " + f"({'schema-validated' if _schema_validate else 'structural checks only'})") + with tempfile.TemporaryDirectory() as root: + workspace = Workspace(Path(root), executable) + for name, scenario in SCENARIOS: + try: + detail = scenario(workspace) + print(f" ✔ {name}: {detail}") + except Exception as error: # noqa: BLE001 - keep running, report all + failures.append(name) + print(f" ✘ {name}: {error}") + if failures: + print(f"{len(failures)} of {len(SCENARIOS)} scenarios failed: {', '.join(failures)}") + return 1 + print(f"All {len(SCENARIOS)} scenarios passed.") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("usage: release_smoke.py ") + sys.exit(main(sys.argv[1])) diff --git a/setup.py b/setup.py index 0fa7155..9ffc839 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="wild", - version="1.3.0", + version="1.3.1", packages=find_packages(), package_data={ "diffgraph": [ diff --git a/tests/test_artifact_dispatch.py b/tests/test_artifact_dispatch.py index 595e55c..3dfc075 100644 --- a/tests/test_artifact_dispatch.py +++ b/tests/test_artifact_dispatch.py @@ -198,7 +198,8 @@ def test_format_json_explicit_output_is_atomic_and_reports_on_stderr(tmp_path, m (["--structural-json", "legacy.json", "--format", "json", "diff"], "cannot be combined"), (["--structural-json", "legacy.json", "--output", "other.json", "diff"], "cannot be combined"), (["diff", "--format", "terminal", "--output", "terminal.txt"], "writes to stdout"), - (["status", "--format", "json"], "can only be used with 'diff'"), + # wild options apply before the command; after it they belong to git. + (["--format", "json", "status"], "can only be used with 'diff'"), ], ) def test_canonical_option_conflicts_are_usage_errors(arguments, message): diff --git a/tests/test_cli_passthrough.py b/tests/test_cli_passthrough.py new file mode 100644 index 0000000..1514281 --- /dev/null +++ b/tests/test_cli_passthrough.py @@ -0,0 +1,68 @@ +"""Non-diff commands go to git verbatim, even with options wild also defines.""" + +import subprocess + +import pytest +from click.testing import CliRunner + +import diffgraph.cli as cli + + +@pytest.fixture +def git_calls(monkeypatch): + calls = [] + + def fake_run(command, *args, **kwargs): + calls.append(list(command)) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(cli.subprocess, "run", fake_run) + return calls + + +@pytest.mark.parametrize( + "argv", + [ + ["log", "-1", "--format=%h %s"], + ["log", "--format", "oneline"], + ["show", "--no-patch", "--format=%an", "HEAD"], + ["format-patch", "-1", "-o", "out-dir"], + ["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"], + ["status", "--short"], + ["log", "--", "--output"], + ], +) +def test_non_diff_commands_reach_git_verbatim(argv, git_calls): + result = CliRunner().invoke(cli.main, argv) + + assert result.exit_code == 0, result.output + assert git_calls == [["git", *argv]] + + +def test_wild_options_before_the_command_are_still_wild_options(git_calls): + # A wild-only option before a non-diff command is a usage error, as before. + result = CliRunner().invoke(cli.main, ["--format", "json", "status"]) + + assert result.exit_code == 2 + assert git_calls == [] + + +def test_version_and_help_are_unaffected(): + assert CliRunner().invoke(cli.main, ["--version"]).exit_code == 0 + assert "Usage" in CliRunner().invoke(cli.main, ["--help"]).output + + +@pytest.mark.parametrize( + "argv", + [ + ["-C", "/repo", "log", "--format=%h"], + ["-c", "core.pager=cat", "log", "-o", "x"], + ["--no-pager", "show", "--format=%s"], + ["-C", "/repo", "diff", "--stat"], + ], +) +def test_git_global_options_start_a_verbatim_passthrough(argv, git_calls): + result = CliRunner().invoke(cli.main, argv) + + assert result.exit_code == 0, result.output + assert git_calls == [["git", *argv]] diff --git a/tests/test_llm_summaries.py b/tests/test_llm_summaries.py index 37f13b4..0869d28 100644 --- a/tests/test_llm_summaries.py +++ b/tests/test_llm_summaries.py @@ -533,7 +533,7 @@ def rejected(self, model, messages, max_tokens=2048): @pytest.mark.parametrize( "args", - [["status", "--llm", "gpt-5-mini"], ["diff", "--format", "legacy-html", "--llm", "gpt-5-mini"]], + [["--llm", "gpt-5-mini", "status"], ["diff", "--format", "legacy-html", "--llm", "gpt-5-mini"]], ) def test_cli_llm_usage_errors(args, repo): assert CliRunner().invoke(cli.main, args).exit_code == 2 diff --git a/tests/test_release_build.py b/tests/test_release_build.py index f6e69d8..cd33e35 100644 --- a/tests/test_release_build.py +++ b/tests/test_release_build.py @@ -9,7 +9,7 @@ from diffgraph import __version__ -def test_release_build_command_does_not_add_data_files(tmp_path): +def test_release_build_bundles_only_the_allowlisted_schema(tmp_path): command = build.build_command( output_dir=tmp_path / "release", work_dir=tmp_path / "work", @@ -18,9 +18,28 @@ def test_release_build_command_does_not_add_data_files(tmp_path): ) assert "--onefile" in command - assert "--add-data" not in command assert "--add-binary" not in command assert all(".env" not in argument for argument in command) + data = [command[index + 1] for index, argument in enumerate(command) if argument == "--add-data"] + # Exactly the runtime schema, as one file into its package directory. + assert data == [ + f"{build.ROOT / 'diffgraph' / 'schema' / 'diffgraph-v2.schema.json'}{build.os.pathsep}diffgraph/schema" + ] + + +def test_release_build_bundles_every_runtime_resource(): + """Every importlib.resources path the CLI reads must be bundled, or the + native binary fails at runtime (cli-v1.3.0 could not load its schema).""" + from diffgraph import contract + + bundled = {path.relative_to(build.ROOT / "diffgraph").as_posix() for path in build.PACKAGED_DATA_FILES} + assert contract._SCHEMA_RESOURCE in bundled + + +def test_release_build_refuses_data_outside_the_package(tmp_path, monkeypatch): + monkeypatch.setattr(build, "PACKAGED_DATA_FILES", (build.ROOT / "README.md",)) + with pytest.raises(SystemExit, match="missing or outside the package"): + build.packaged_data_arguments() def test_release_build_rejects_local_dotenv(tmp_path): @@ -59,6 +78,10 @@ def test_release_build_rejects_checkout_or_ancestor_output_dir_before_cleanup( def test_native_binary_reports_version(tmp_path: Path) -> None: pytest.importorskip("PyInstaller") + forbidden = build.find_forbidden_files(build.ROOT) + if forbidden: + # The builder correctly refuses such a workspace; CI never has one. + pytest.skip(f"local credential file(s) present: {', '.join(p.name for p in forbidden)}") output_dir = tmp_path / "release" subprocess.run( [sys.executable, "build.py", "--output-dir", str(output_dir), "--name", "wild"], @@ -68,3 +91,49 @@ def test_native_binary_reports_version(tmp_path: Path) -> None: binary = output_dir / ("wild.exe" if sys.platform == "win32" else "wild") result = subprocess.run([str(binary), "--version"], check=True, capture_output=True, text=True) assert result.stdout.strip() == f"wild, version {__version__}" + # A real diff exercises the bundled runtime data (the schema). + smoke = subprocess.run( + [sys.executable, "scripts/release_smoke.py", str(binary)], + cwd=build.ROOT, capture_output=True, text=True, + ) + assert smoke.returncode == 0, smoke.stdout + smoke.stderr + assert "scenarios passed" in smoke.stdout + + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX launcher script") +def test_release_scenario_suite_passes_on_this_checkout(tmp_path): + """The release scenario suite (VS Code extension commands, core CLI + forms, exit codes, git passthrough) passes on this checkout's CLI.""" + # A launcher for this checkout, never whichever `wild` is first on PATH. + launcher = tmp_path / "wild" + launcher.write_text( + f"#!{sys.executable}\n" + "import sys\n" + f"sys.path.insert(0, {str(build.ROOT)!r})\n" + "from diffgraph.cli import main\n" + "main(prog_name='wild')\n", + encoding="utf-8", + ) + launcher.chmod(0o755) + smoke = subprocess.run( + [sys.executable, "scripts/release_smoke.py", str(launcher)], + cwd=build.ROOT, capture_output=True, text=True, + ) + assert smoke.returncode == 0, smoke.stdout + smoke.stderr + assert "schema-validated" in smoke.stdout and "scenarios passed" in smoke.stdout + + + +def test_release_build_resolves_symlinks_before_the_package_check(tmp_path, monkeypatch): + outside = tmp_path / "secret.json" + outside.write_text("{}", encoding="utf-8") + schema_dir = tmp_path / "diffgraph" / "schema" + schema_dir.mkdir(parents=True) + link = schema_dir / "diffgraph-v2.schema.json" + link.symlink_to(outside) + monkeypatch.setattr(build, "ROOT", tmp_path) + monkeypatch.setattr(build, "PACKAGED_DATA_FILES", (link,)) + + with pytest.raises(SystemExit, match="missing or outside the package"): + build.packaged_data_arguments()