From f1012b1eaf7163510fc9ae3395d90a229f1067ad Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 06:09:37 -0800 Subject: [PATCH 1/3] fix(kt): resolve Gradle executables before wrapping them Co-Authored-By: Codex --- .github/justfile | 1 + sh/kt/check.sh | 3 +- sh/kt/gradle.test.py | 131 +++++++++++++++++++++++++++++++++++++++++++ sh/kt/package.sh | 2 +- 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 sh/kt/gradle.test.py diff --git a/.github/justfile b/.github/justfile index 6804c4b08f8..da2469bf14d 100644 --- a/.github/justfile +++ b/.github/justfile @@ -56,6 +56,7 @@ test: bun test sh/gh/ffi.test.ts sh/gh/cpp.test.ts python3 sh/rs/package-rename.test.py python3 sh/dart/build.test.py + python3 sh/kt/gradle.test.py python3 sh/rs/select.test.py python3 sh/rs/config.test.py python3 sh/js/workspace.test.py diff --git a/sh/kt/check.sh b/sh/kt/check.sh index fbdebcce8d4..46c2e652464 100644 --- a/sh/kt/check.sh +++ b/sh/kt/check.sh @@ -28,7 +28,8 @@ if ! command -v java >/dev/null 2>&1; then exit 1 fi -GRADLE_CMD="${GRADLE_CMD:-$(command -v gradle || true)}" +# Resolve an executable before defining gradle(), so an inherited function cannot recurse into it. +GRADLE_CMD="$(type -P "${GRADLE_CMD:-gradle}" || true)" if [[ -z "$GRADLE_CMD" ]]; then echo "kt check: gradle not on PATH; run 'nix develop' or use 'just kt generate' to only regenerate bindings" >&2 exit 1 diff --git a/sh/kt/gradle.test.py b/sh/kt/gradle.test.py new file mode 100644 index 00000000000..88a4e79444a --- /dev/null +++ b/sh/kt/gradle.test.py @@ -0,0 +1,131 @@ +"""Kotlin runners select executables even when the shell exports Gradle functions.""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BASH = shutil.which("bash") + + +class GradleTest(unittest.TestCase): + def setUp(self): + scratch = ROOT / ".tmp" + scratch.mkdir(exist_ok=True) + self.directory = tempfile.TemporaryDirectory(dir=scratch) + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.tools = self.root / "tools" + self.tools.mkdir() + self.calls = self.root / "calls.jsonl" + for name in ("bash", "dirname", "mkdir", "rm", "cp"): + (self.tools / name).symlink_to(shutil.which(name)) + (self.tools / "python3").symlink_to(sys.executable) + self.write("tools/java", "exit 0\n") + for name in ("check.sh", "package.sh"): + target = self.root / "sh/kt" / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(ROOT / "sh/kt" / name, target) + for name in ("format.sh", "generate.sh"): + self.write(f"sh/kt/{name}", "exit 0\n") + self.write("doc/lib/samples.sh", "exit 0\n") + (self.root / "sh/kt/check-poms.py").write_text("") + (self.root / "kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/docs").mkdir(parents=True) + source = self.root / "bindings/uniffi/moq/moq.kt" + source.parent.mkdir(parents=True) + source.write_text("") + self.gradle("gradle") + self.gradle("custom-gradle") + + def write(self, name, source): + target = self.root / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"#!{BASH}\n{source}") + target.chmod(0o755) + + def gradle(self, name): + target = self.tools / name + target.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + 'with open(os.environ["GRADLE_CALLS"], "a") as output:\n' + " output.write(json.dumps({\n" + ' "executable": sys.argv[0], "args": sys.argv[1:],\n' + ' "runner_keys": [key for key in os.environ if key.startswith(("ACTIONS_", "GITHUB_", "RUNNER_"))],\n' + ' }) + "\\n")\n' + 'sys.exit(int(os.environ.get("GRADLE_STATUS", "0")))\n' + ) + target.chmod(0o755) + + def run_script(self, script, **changes): + self.calls.unlink(missing_ok=True) + # An imported function must never satisfy executable discovery. FUNCNEST + # turns a recursive wrapper into a deterministic failure in the fixture. + env = { + "PATH": str(self.tools), + "HOME": str(self.root), + "LOCAL_SUPERVISED": "1", + "BASH_ENV": os.devnull, + "BASH_FUNC_gradle%%": "() { echo 'inherited function called' >&2; return 99; }", + "FUNCNEST": "10", + "GRADLE_CALLS": str(self.calls), + "ACTIONS_TEST": "fixture", + "GITHUB_TEST": "fixture", + "RUNNER_TEST": "fixture", + **changes, + } + args = [BASH, f"sh/kt/{script}.sh"] + if script == "package": + args += ["--version", "0.0.0-dev", "--lib-dir", "libs", "--bindings-dir", "bindings", "--output", "dist"] + return subprocess.run(args, cwd=self.root, env=env, text=True, capture_output=True) + + def recorded(self): + return [json.loads(line) for line in self.calls.read_text().splitlines()] + + def test_path_executable_wins_over_exported_function(self): + for script in ("check", "package"): + for override in ({}, {"GRADLE_CMD": "gradle"}): + with self.subTest(script=script, override=override): + result = self.run_script(script, **override) + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.recorded() + self.assertEqual(len(calls), 2 if script == "check" else 1) + self.assertTrue(all(Path(call["executable"]) == self.tools / "gradle" for call in calls)) + self.assertIn(":moq:jvmTest" if script == "check" else ":moq-ffi:assemble", calls[0]["args"]) + if script == "check": + self.assertTrue(all(call["runner_keys"] == [] for call in calls)) + self.assertIn(":moq:generatePomFileForJvmPublication", calls[1]["args"]) + + def test_explicit_executable_is_preserved(self): + for script in ("check", "package"): + for override in ("custom-gradle", "tools/custom-gradle", str(self.tools / "custom-gradle")): + with self.subTest(script=script, override=override): + result = self.run_script(script, GRADLE_CMD=override) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(all(Path(call["executable"]).name == "custom-gradle" for call in self.recorded())) + + def test_function_without_executable_is_refused(self): + (self.tools / "gradle").unlink() + for script in ("check", "package"): + with self.subTest(script=script): + result = self.run_script(script) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("gradle not on PATH", result.stderr) + self.assertNotIn("inherited function called", result.stderr) + self.assertFalse(self.calls.exists()) + + def test_gradle_failure_stops_the_runner(self): + for script in ("check", "package"): + with self.subTest(script=script): + result = self.run_script(script, GRADLE_STATUS="7") + self.assertEqual(result.returncode, 7, result.stderr) + self.assertEqual(len(self.recorded()), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/sh/kt/package.sh b/sh/kt/package.sh index 03c9cc13382..bcb00ac5750 100755 --- a/sh/kt/package.sh +++ b/sh/kt/package.sh @@ -173,7 +173,7 @@ if [[ "$HAVE_ANDROID_LIBS" == true ]]; then GRADLE_ARGS+=("-Pandroid.enabled=true") fi -GRADLE_CMD="${GRADLE_CMD:-$(command -v gradle || true)}" +GRADLE_CMD="$(type -P "${GRADLE_CMD:-gradle}" || true)" [[ -n "$GRADLE_CMD" ]] || { echo "Error: gradle not on PATH" >&2 exit 1 From ca94aed7a953a2365b5e6ac051df01de2945f7e3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 06:11:40 -0800 Subject: [PATCH 2/3] test(kt): keep the fixture within the Python line limit Co-Authored-By: Codex --- sh/kt/gradle.test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sh/kt/gradle.test.py b/sh/kt/gradle.test.py index 88a4e79444a..636a1d34abc 100644 --- a/sh/kt/gradle.test.py +++ b/sh/kt/gradle.test.py @@ -56,7 +56,8 @@ def gradle(self, name): 'with open(os.environ["GRADLE_CALLS"], "a") as output:\n' " output.write(json.dumps({\n" ' "executable": sys.argv[0], "args": sys.argv[1:],\n' - ' "runner_keys": [key for key in os.environ if key.startswith(("ACTIONS_", "GITHUB_", "RUNNER_"))],\n' + ' "runner_keys": [key for key in os.environ\n' + ' if key.startswith(("ACTIONS_", "GITHUB_", "RUNNER_"))],\n' ' }) + "\\n")\n' 'sys.exit(int(os.environ.get("GRADLE_STATUS", "0")))\n' ) From 403bdd755fc0f1b00a1967f12a77941c09228807 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 16:11:11 -0800 Subject: [PATCH 3/3] fix(kt): resolve Gradle in the bench runner and name a missing override bench.sh now resolves GRADLE_CMD with `type -P` like the check and package runners, and every runner names the value it could not resolve instead of claiming gradle is missing from PATH. The fixture covers all three runners and the named-override error. Co-Authored-By: Claude Opus 5.5 --- sh/kt/bench.sh | 9 ++++++++- sh/kt/check.sh | 9 +++++---- sh/kt/gradle.test.py | 25 ++++++++++++++++++------- sh/kt/package.sh | 7 ++++--- 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/sh/kt/bench.sh b/sh/kt/bench.sh index 57e61a43cdd..a7074cc4428 100755 --- a/sh/kt/bench.sh +++ b/sh/kt/bench.sh @@ -9,6 +9,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" KT_DIR="$(cd "$SCRIPT_DIR/../../kt" && pwd)" export MOQ_BENCH_DIR="${MOQ_BENCH_DIR:-$KT_DIR/../target/bench/ffi}" +# `type -P` resolves only executables, so an inherited shell function named gradle cannot stand in for Gradle. +gradle_name="${GRADLE_CMD:-gradle}" +GRADLE_CMD="$(type -P "$gradle_name")" || { + echo "kt bench: Gradle executable '$gradle_name' not found; run 'nix develop'" >&2 + exit 1 +} + MOQ_FFI_PROFILE=release bash "$SCRIPT_DIR/generate.sh" -gradle -p "$KT_DIR" -Pmoqffi.version=0.0.0-dev :moq:jvmTest --tests dev.moq.FfiBench --rerun +"$GRADLE_CMD" -p "$KT_DIR" -Pmoqffi.version=0.0.0-dev :moq:jvmTest --tests dev.moq.FfiBench --rerun echo "kt bench: wrote $MOQ_BENCH_DIR/kotlin.json" diff --git a/sh/kt/check.sh b/sh/kt/check.sh index 46c2e652464..3d4346faf9e 100644 --- a/sh/kt/check.sh +++ b/sh/kt/check.sh @@ -28,10 +28,11 @@ if ! command -v java >/dev/null 2>&1; then exit 1 fi -# Resolve an executable before defining gradle(), so an inherited function cannot recurse into it. -GRADLE_CMD="$(type -P "${GRADLE_CMD:-gradle}" || true)" -if [[ -z "$GRADLE_CMD" ]]; then - echo "kt check: gradle not on PATH; run 'nix develop' or use 'just kt generate' to only regenerate bindings" >&2 +# `type -P` resolves only executables, so an inherited shell function named gradle can neither +# stand in for Gradle nor make the gradle() wrapper below call itself. +gradle_name="${GRADLE_CMD:-gradle}" +if ! GRADLE_CMD="$(type -P "$gradle_name")"; then + echo "kt check: Gradle executable '$gradle_name' not found; run 'nix develop' or use 'just kt generate' to only regenerate bindings" >&2 exit 1 fi diff --git a/sh/kt/gradle.test.py b/sh/kt/gradle.test.py index 636a1d34abc..60551f94ec4 100644 --- a/sh/kt/gradle.test.py +++ b/sh/kt/gradle.test.py @@ -11,6 +11,7 @@ ROOT = Path(__file__).resolve().parents[2] BASH = shutil.which("bash") +RUNNERS = ("check", "package", "bench") class GradleTest(unittest.TestCase): @@ -27,7 +28,7 @@ def setUp(self): (self.tools / name).symlink_to(shutil.which(name)) (self.tools / "python3").symlink_to(sys.executable) self.write("tools/java", "exit 0\n") - for name in ("check.sh", "package.sh"): + for name in (f"{runner}.sh" for runner in RUNNERS): target = self.root / "sh/kt" / name target.parent.mkdir(parents=True, exist_ok=True) shutil.copy(ROOT / "sh/kt" / name, target) @@ -89,7 +90,8 @@ def recorded(self): return [json.loads(line) for line in self.calls.read_text().splitlines()] def test_path_executable_wins_over_exported_function(self): - for script in ("check", "package"): + first = {"check": ":moq:jvmTest", "package": ":moq-ffi:assemble", "bench": "dev.moq.FfiBench"} + for script in RUNNERS: for override in ({}, {"GRADLE_CMD": "gradle"}): with self.subTest(script=script, override=override): result = self.run_script(script, **override) @@ -97,13 +99,13 @@ def test_path_executable_wins_over_exported_function(self): calls = self.recorded() self.assertEqual(len(calls), 2 if script == "check" else 1) self.assertTrue(all(Path(call["executable"]) == self.tools / "gradle" for call in calls)) - self.assertIn(":moq:jvmTest" if script == "check" else ":moq-ffi:assemble", calls[0]["args"]) + self.assertIn(first[script], calls[0]["args"]) if script == "check": self.assertTrue(all(call["runner_keys"] == [] for call in calls)) self.assertIn(":moq:generatePomFileForJvmPublication", calls[1]["args"]) def test_explicit_executable_is_preserved(self): - for script in ("check", "package"): + for script in RUNNERS: for override in ("custom-gradle", "tools/custom-gradle", str(self.tools / "custom-gradle")): with self.subTest(script=script, override=override): result = self.run_script(script, GRADLE_CMD=override) @@ -112,20 +114,29 @@ def test_explicit_executable_is_preserved(self): def test_function_without_executable_is_refused(self): (self.tools / "gradle").unlink() - for script in ("check", "package"): + for script in RUNNERS: with self.subTest(script=script): result = self.run_script(script) self.assertEqual(result.returncode, 1, result.stderr) - self.assertIn("gradle not on PATH", result.stderr) + self.assertIn("Gradle executable 'gradle' not found", result.stderr) self.assertNotIn("inherited function called", result.stderr) self.assertFalse(self.calls.exists()) + def test_missing_override_is_named(self): + for script in RUNNERS: + with self.subTest(script=script): + result = self.run_script(script, GRADLE_CMD="tools/missing-gradle") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("Gradle executable 'tools/missing-gradle' not found", result.stderr) + self.assertFalse(self.calls.exists()) + def test_gradle_failure_stops_the_runner(self): - for script in ("check", "package"): + for script in RUNNERS: with self.subTest(script=script): result = self.run_script(script, GRADLE_STATUS="7") self.assertEqual(result.returncode, 7, result.stderr) self.assertEqual(len(self.recorded()), 1) + self.assertNotIn("kt bench: wrote", result.stdout) if __name__ == "__main__": diff --git a/sh/kt/package.sh b/sh/kt/package.sh index bcb00ac5750..58d429934b3 100755 --- a/sh/kt/package.sh +++ b/sh/kt/package.sh @@ -173,9 +173,10 @@ if [[ "$HAVE_ANDROID_LIBS" == true ]]; then GRADLE_ARGS+=("-Pandroid.enabled=true") fi -GRADLE_CMD="$(type -P "${GRADLE_CMD:-gradle}" || true)" -[[ -n "$GRADLE_CMD" ]] || { - echo "Error: gradle not on PATH" >&2 +# `type -P` resolves only executables, so an inherited shell function named gradle cannot stand in for Gradle. +gradle_name="${GRADLE_CMD:-gradle}" +GRADLE_CMD="$(type -P "$gradle_name")" || { + echo "Error: Gradle executable '$gradle_name' not found" >&2 exit 1 }