From cf70381a30e45689d04fc2c08c1a2ff2afc52d13 Mon Sep 17 00:00:00 2001 From: Avikalp Kumar Gupta Date: Thu, 5 Nov 2026 14:56:18 -0800 Subject: [PATCH 1/5] fix(release): bundle the DiffGraph schema in native binaries; smoke a real diff The cli-v1.3.0 binaries cannot run any canonical wild diff: build.py never passed PyInstaller data files (to keep a .env out of releases), so the packaged DiffGraph schema that every artifact is validated against was missing ('could not load canonical DiffGraph schema'). The release smoke test only ran --help, which never loads it. - build.py bundles PACKAGED_DATA_FILES, an explicit allowlist of exact package files the CLI loads at runtime (today: the schema). Entries must be existing files inside the package; the .env refusal is unchanged. - scripts/release_smoke.py (stdlib only) runs --version and a real diff as JSON (schema 2.x with files and symbols), HTML, and terminal in a throwaway repository. Every release matrix target runs it instead of --help, and the native build test runs it too. - Tests pin the allowlist, require every importlib.resources path the CLI reads to be bundled, and reject data outside the package. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 12 ++--- build.py | 29 ++++++++++-- scripts/release_smoke.py | 84 +++++++++++++++++++++++++++++++++++ tests/test_release_build.py | 46 ++++++++++++++++++- 4 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 scripts/release_smoke.py 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/build.py b/build.py index 7ea80e5..d19b8ac 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,20 @@ 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" + for path in PACKAGED_DATA_FILES: + if not path.is_file() or package_root not in path.parents: + raise SystemExit(f"packaged data file is missing or outside the package: {path}") + destination = path.parent.relative_to(ROOT).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 +82,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/scripts/release_smoke.py b/scripts/release_smoke.py new file mode 100644 index 0000000..dc98c62 --- /dev/null +++ b/scripts/release_smoke.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Smoke-test a native ``wild`` executable on a real diff (stdlib only). + +Usage: python scripts/release_smoke.py + +``--help`` alone cannot catch a binary that is missing runtime data (the +cli-v1.3.0 binaries could not load their DiffGraph schema), so this runs the +canonical JSON and HTML outputs in a throwaway repository. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + + +def main(binary: str) -> int: + env = { + **os.environ, + "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", + } + executable = str(Path(binary).resolve()) + with tempfile.TemporaryDirectory() as workspace: + repo = Path(workspace) / "repo" + repo.mkdir() + + def git(*args: str) -> None: + subprocess.run(["git", *args], cwd=repo, env=env, check=True, capture_output=True) + + def wild(*args: str) -> str: + result = subprocess.run( + [executable, *args], cwd=repo, env=env, capture_output=True, text=True + ) + if result.returncode != 0: + tail = "\n".join((result.stderr or result.stdout).strip().splitlines()[-5:]) + raise SystemExit(f"`wild {' '.join(args)}` exited with {result.returncode}:\n{tail}") + return result.stdout + + git("init", "-q") + (repo / "lib.py").write_text("def f(a):\n return a\n", encoding="utf-8") + (repo / "use.py").write_text("from lib import f\n\ndef g():\n return f(1)\n", encoding="utf-8") + git("add", ".") + git("commit", "-q", "-m", "base") + (repo / "lib.py").write_text("def f(a, b=0):\n return a + b\n", encoding="utf-8") + + version = wild("--version").strip() + print(version) + + json_path = Path(workspace) / "diffgraph.json" + wild("diff", "--format", "json", "--output", str(json_path)) + artifact = json.loads(json_path.read_text(encoding="utf-8")) + if not re.fullmatch(r"2\.\d+", str(artifact.get("schema_version"))): + raise SystemExit(f"unexpected schema_version {artifact.get('schema_version')!r}") + if not artifact.get("files") or not artifact.get("symbols"): + raise SystemExit("the JSON artifact has no files or symbols for a real change") + print(f"json: schema {artifact['schema_version']}, {len(artifact['files'])} files, " + f"{len(artifact['symbols'])} symbols") + + html_path = Path(workspace) / "diffgraph.html" + wild("diff", "--format", "html", "--output", str(html_path), "--no-open") + report = html_path.read_text(encoding="utf-8") + if not report.lower().startswith("") or 'id="diffgraph-artifact"' not in report: + raise SystemExit("the HTML report is not a self-contained DiffGraph document") + print(f"html: {len(report)} bytes") + + wild("diff") # terminal output + print("terminal: ok") + 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/tests/test_release_build.py b/tests/test_release_build.py index f6e69d8..79c9178 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): @@ -68,3 +87,26 @@ 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 "json: schema 2." in smoke.stdout + + + +def test_release_smoke_script_checks_a_real_diff(): + """The release smoke test passes on a working CLI (the dev install).""" + import shutil + + wild = shutil.which("wild") + if wild is None: + pytest.skip("the wild console script is not installed") + smoke = subprocess.run( + [sys.executable, "scripts/release_smoke.py", wild], + cwd=build.ROOT, capture_output=True, text=True, + ) + assert smoke.returncode == 0, smoke.stdout + smoke.stderr + assert "json: schema 2." in smoke.stdout and "html:" in smoke.stdout From 0b4d0f82d010da42f3dacdd6512f60e7f901cd4d Mon Sep 17 00:00:00 2001 From: Avikalp Kumar Gupta Date: Thu, 5 Nov 2026 14:58:49 -0800 Subject: [PATCH 2/5] chore(release): 1.3.1 Patch release carrying the native-binary schema fix; cli-v1.3.0 binaries cannot run wild diff. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++++ diffgraph/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 372de38..df265c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ 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. +- Release smoke tests run a real diff (JSON, HTML, terminal) on every + platform instead of `--help`, which could not catch missing runtime data. + ## [1.3.0] - 2026-10-26 ### Added 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/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": [ From b064aca3ca12b3cf95366399bbaf9af86bdc7bd6 Mon Sep 17 00:00:00 2001 From: Avikalp Kumar Gupta Date: Thu, 5 Nov 2026 15:11:11 -0800 Subject: [PATCH 3/5] fix(cli): pass non-diff commands to git verbatim; scenario-suite release smoke - wild parsed its own --format/--output/--no-open options for every command, so git passthrough broke for 'wild log --format=...', 'wild show --format=...', and 'wild format-patch -o DIR'. Arguments from the first non-diff command onward now go to git untouched; wild's options before the command, --version/--help, and diff are unchanged. - scripts/release_smoke.py is now an 18-scenario end-to-end suite: 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, exit codes 1/2, and git passthrough. JSON artifacts are validated with the packaged schema. It runs on every release platform's native binary and, via pytest, on the installed CLI in CI. - The native-build test skips (with the reason) when a local .env makes the builder refuse the workspace. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +- diffgraph/cli.py | 33 ++- scripts/release_smoke.py | 404 +++++++++++++++++++++++++++++----- tests/test_cli_passthrough.py | 52 +++++ tests/test_release_build.py | 12 +- 5 files changed, 447 insertions(+), 67 deletions(-) create mode 100644 tests/test_cli_passthrough.py diff --git a/CHANGELOG.md b/CHANGELOG.md index df265c8..adf3522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (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. -- Release smoke tests run a real diff (JSON, HTML, terminal) on every - platform instead of `--help`, which could not catch missing runtime data. +- 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 diff --git a/diffgraph/cli.py b/diffgraph/cli.py index fd766db..cb0fc98 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(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, args): + """Index of the first positional argument after wild's own options.""" + takes_value = set() + known = set() + 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 + if argument.startswith("-"): + return None # an unknown option: let click report it + return index + return None def _terminal_options( diff --git a/scripts/release_smoke.py b/scripts/release_smoke.py index dc98c62..6e78046 100644 --- a/scripts/release_smoke.py +++ b/scripts/release_smoke.py @@ -1,11 +1,17 @@ #!/usr/bin/env python3 -"""Smoke-test a native ``wild`` executable on a real diff (stdlib only). +"""End-to-end scenario suite for a ``wild`` executable (native or installed). Usage: python scripts/release_smoke.py -``--help`` alone cannot catch a binary that is missing runtime data (the -cli-v1.3.0 binaries could not load their DiffGraph schema), so this runs the -canonical JSON and HTML outputs in a throwaway repository. +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 @@ -17,64 +23,342 @@ 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() + self.env = { + **os.environ, + # 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) -> int: - env = { - **os.environ, - "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", - } - executable = str(Path(binary).resolve()) - with tempfile.TemporaryDirectory() as workspace: - repo = Path(workspace) / "repo" - repo.mkdir() - - def git(*args: str) -> None: - subprocess.run(["git", *args], cwd=repo, env=env, check=True, capture_output=True) - - def wild(*args: str) -> str: - result = subprocess.run( - [executable, *args], cwd=repo, env=env, capture_output=True, text=True - ) - if result.returncode != 0: - tail = "\n".join((result.stderr or result.stdout).strip().splitlines()[-5:]) - raise SystemExit(f"`wild {' '.join(args)}` exited with {result.returncode}:\n{tail}") - return result.stdout - - git("init", "-q") - (repo / "lib.py").write_text("def f(a):\n return a\n", encoding="utf-8") - (repo / "use.py").write_text("from lib import f\n\ndef g():\n return f(1)\n", encoding="utf-8") - git("add", ".") - git("commit", "-q", "-m", "base") - (repo / "lib.py").write_text("def f(a, b=0):\n return a + b\n", encoding="utf-8") - - version = wild("--version").strip() - print(version) - - json_path = Path(workspace) / "diffgraph.json" - wild("diff", "--format", "json", "--output", str(json_path)) - artifact = json.loads(json_path.read_text(encoding="utf-8")) - if not re.fullmatch(r"2\.\d+", str(artifact.get("schema_version"))): - raise SystemExit(f"unexpected schema_version {artifact.get('schema_version')!r}") - if not artifact.get("files") or not artifact.get("symbols"): - raise SystemExit("the JSON artifact has no files or symbols for a real change") - print(f"json: schema {artifact['schema_version']}, {len(artifact['files'])} files, " - f"{len(artifact['symbols'])} symbols") - - html_path = Path(workspace) / "diffgraph.html" - wild("diff", "--format", "html", "--output", str(html_path), "--no-open") - report = html_path.read_text(encoding="utf-8") - if not report.lower().startswith("") or 'id="diffgraph-artifact"' not in report: - raise SystemExit("the HTML report is not a self-contained DiffGraph document") - print(f"html: {len(report)} bytes") - - wild("diff") # terminal output - print("terminal: ok") +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 diff --git a/tests/test_cli_passthrough.py b/tests/test_cli_passthrough.py new file mode 100644 index 0000000..07a6d85 --- /dev/null +++ b/tests/test_cli_passthrough.py @@ -0,0 +1,52 @@ +"""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 diff --git a/tests/test_release_build.py b/tests/test_release_build.py index 79c9178..a6034a2 100644 --- a/tests/test_release_build.py +++ b/tests/test_release_build.py @@ -78,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"], @@ -93,12 +97,12 @@ def test_native_binary_reports_version(tmp_path: Path) -> None: cwd=build.ROOT, capture_output=True, text=True, ) assert smoke.returncode == 0, smoke.stdout + smoke.stderr - assert "json: schema 2." in smoke.stdout + assert "scenarios passed" in smoke.stdout -def test_release_smoke_script_checks_a_real_diff(): - """The release smoke test passes on a working CLI (the dev install).""" +def test_release_scenario_suite_passes_on_the_installed_cli(): + """The release scenario suite (VS Code extension commands, core CLI forms, exit codes, git passthrough) passes on the dev install.""" import shutil wild = shutil.which("wild") @@ -109,4 +113,4 @@ def test_release_smoke_script_checks_a_real_diff(): cwd=build.ROOT, capture_output=True, text=True, ) assert smoke.returncode == 0, smoke.stdout + smoke.stderr - assert "json: schema 2." in smoke.stdout and "html:" in smoke.stdout + assert "schema-validated" in smoke.stdout and "scenarios passed" in smoke.stdout From 261c976145e36c5bb004b84932fd5a1c99a8c0c7 Mon Sep 17 00:00:00 2001 From: Avikalp Kumar Gupta Date: Thu, 5 Nov 2026 15:28:59 -0800 Subject: [PATCH 4/5] test: wild options before a non-diff command stay usage errors After the passthrough fix, options after a non-diff command belong to git ('wild status --format json' is git's error, as for 'git status --format json'). The usage-error cases now put wild's option before the command, which is what they guard. Co-Authored-By: Claude Opus 5.5 --- tests/test_artifact_dispatch.py | 3 ++- tests/test_llm_summaries.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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_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 From 1f6dbeb893f3e80bfecae8b47d4a88fdee918e9f Mon Sep 17 00:00:00 2001 From: Avikalp Kumar Gupta Date: Fri, 6 Nov 2026 00:28:11 -0800 Subject: [PATCH 5/5] fix: address CodeRabbit review on #96 - build.py resolves symlinks before the package-boundary check, so a link inside diffgraph/ cannot pull an outside file into the binary. - Git global options before the command (-C DIR, -c k=v, --no-pager) start a verbatim passthrough, so 'wild -C /repo log --format=%h' reaches git. Click's --help is still wild's own. - The scenario suite drops inherited GIT_* variables, so running it from a hook (GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE) cannot redirect it to another repository. - The suite test runs this checkout's CLI through a launcher instead of whichever wild is first on PATH. Co-Authored-By: Claude Opus 5.5 --- build.py | 11 ++++++---- diffgraph/cli.py | 12 +++++------ scripts/release_smoke.py | 5 ++++- tests/test_cli_passthrough.py | 16 ++++++++++++++ tests/test_release_build.py | 39 ++++++++++++++++++++++++++++------- 5 files changed, 64 insertions(+), 19 deletions(-) diff --git a/build.py b/build.py index d19b8ac..69951c8 100644 --- a/build.py +++ b/build.py @@ -55,11 +55,14 @@ 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" - for path in PACKAGED_DATA_FILES: + 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: {path}") - destination = path.parent.relative_to(ROOT).as_posix() + 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 diff --git a/diffgraph/cli.py b/diffgraph/cli.py index cb0fc98..8f5562c 100644 --- a/diffgraph/cli.py +++ b/diffgraph/cli.py @@ -122,7 +122,7 @@ class _RawArgsCommand(click.Command): def parse_args(self, ctx, args): ctx.meta["raw_args"] = tuple(args) - command_index = self._command_index(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` @@ -131,10 +131,10 @@ def parse_args(self, ctx, args): ctx.params["args"] = tuple(args[command_index:]) return remaining - def _command_index(self, args): - """Index of the first positional argument after wild's own options.""" + def _command_index(self, ctx, args): + """Index of the first argument after wild's own leading options.""" takes_value = set() - known = 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] @@ -150,8 +150,8 @@ def _command_index(self, args): if argument.startswith("-") and name in known: index += 2 if name in takes_value and "=" not in argument else 1 continue - if argument.startswith("-"): - return None # an unknown option: let click report it + # 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 diff --git a/scripts/release_smoke.py b/scripts/release_smoke.py index 6e78046..8c276af 100644 --- a/scripts/release_smoke.py +++ b/scripts/release_smoke.py @@ -51,8 +51,11 @@ def __init__(self, root: Path, executable: str) -> None: 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 = { - **os.environ, + **inherited, # No user config, relay token, or .env can influence the run. "HOME": str(home), "USERPROFILE": str(home), diff --git a/tests/test_cli_passthrough.py b/tests/test_cli_passthrough.py index 07a6d85..1514281 100644 --- a/tests/test_cli_passthrough.py +++ b/tests/test_cli_passthrough.py @@ -50,3 +50,19 @@ def test_wild_options_before_the_command_are_still_wild_options(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_release_build.py b/tests/test_release_build.py index a6034a2..cd33e35 100644 --- a/tests/test_release_build.py +++ b/tests/test_release_build.py @@ -101,16 +101,39 @@ def test_native_binary_reports_version(tmp_path: Path) -> None: -def test_release_scenario_suite_passes_on_the_installed_cli(): - """The release scenario suite (VS Code extension commands, core CLI forms, exit codes, git passthrough) passes on the dev install.""" - import shutil - - wild = shutil.which("wild") - if wild is None: - pytest.skip("the wild console script is not installed") +@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", wild], + [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()