diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 8d022850a..840114d0c 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -90,6 +90,8 @@ jobs: run: | bash tools/ci/test-bazel-cache-upload-mode bash tools/ci/test-bazel-remote-probe + bash tools/ci/test-bazel-lock-touches-java + python3 tools/ci/test-bazel-lock-touches-java-properties - name: Compute changed subtrees id: detect @@ -195,12 +197,21 @@ jobs: run_all=false changed="" + # The endpoints the path list was derived from. The content checks + # below must compare exactly these two revisions, not re-derive a + # range of their own, or they could answer about a different diff + # than the one that selected the rows. + diff_base="" + diff_head="" case "$EVENT" in workflow_dispatch) run_all=true ;; pull_request) if ! changed=$(git diff --name-only "origin/${BASE_REF}...HEAD" 2>/dev/null); then echo "diff vs origin/${BASE_REF} failed; running full matrix" run_all=true + else + diff_base=$(git merge-base "origin/${BASE_REF}" HEAD 2>/dev/null || true) + diff_head=$(git rev-parse HEAD 2>/dev/null || true) fi ;; merge_group) @@ -219,6 +230,9 @@ jobs: elif ! changed=$(git diff --name-only "${MERGE_BASE_SHA}...${HEAD_SHA}" 2>/dev/null); then echo "diff ${MERGE_BASE_SHA}...${HEAD_SHA} failed; running full matrix" run_all=true + else + diff_base=$(git merge-base "${MERGE_BASE_SHA}" "${HEAD_SHA}" 2>/dev/null || true) + diff_head="${HEAD_SHA}" fi ;; *) @@ -227,6 +241,9 @@ jobs: elif ! changed=$(git diff --name-only "${BEFORE_SHA}..${HEAD_SHA}" 2>/dev/null); then echo "diff ${BEFORE_SHA}..${HEAD_SHA} failed (rewritten history?); running full matrix" run_all=true + else + diff_base="${BEFORE_SHA}" + diff_head="${HEAD_SHA}" fi ;; esac @@ -274,15 +291,70 @@ jobs: tools/bazel/java/ tools/ci/stage-bazel-java-artifacts ) + # Two of these globs fire far more often than they carry Java + # meaning, so a path hit on them is confirmed against content before + # it schedules all seven Java rows. + # + # NVIDIA/nvcf#777 is the case: its whole lock delta was one Rust + # crate_universe extension and its MODULE.bazel delta was a single + # blank line, yet it scheduled every Java row. Those rows run on + # GitHub-hosted runners at max-parallel 4 and were the queue entry's + # long pole, cloud-functions alone taking 19 minutes, rebuilding from + # near scratch because a repin invalidates external repository state. + # + # Both checks fail closed. Without a usable diff range, or on any + # error, the path hit stands and Java runs. + java_content_gated() { + case "$1" in MODULE.bazel|MODULE.bazel.lock) return 0 ;; *) return 1 ;; esac + } + java_hit_is_real() { + local f="$1" + if [ -z "$diff_base" ] || [ -z "$diff_head" ]; then + echo " no diff range for $f; treating as Java-relevant" + return 0 + fi + case "$f" in + MODULE.bazel) + # Whitespace and blank-line edits cannot change resolution. + if git diff --quiet --ignore-all-space --ignore-blank-lines \ + "$diff_base" "$diff_head" -- MODULE.bazel 2>/dev/null; then + echo " MODULE.bazel changed only in whitespace; not Java-relevant" + return 1 + fi + return 0 + ;; + MODULE.bazel.lock) + local verdict + verdict=$(python3 tools/ci/bazel-lock-touches-java "$diff_base" "$diff_head" 2>&1 >/dev/null || true) + [ -n "$verdict" ] && echo " $verdict" + if [ "$(python3 tools/ci/bazel-lock-touches-java "$diff_base" "$diff_head" 2>/dev/null)" = "false" ]; then + return 1 + fi + return 0 + ;; + esac + return 0 + } + java_shared_changed=false if [ "$run_all" != "true" ]; then for g in "${JAVA_SHARED_GLOBS[@]}"; do + # The prefix test means the MODULE.bazel glob also matches + # MODULE.bazel.lock, so match the gated files exactly and let the + # lock be judged by its own entry. + if java_content_gated "$g"; then + printf '%s\n' "$changed" | grep -qxF "$g" || continue + java_hit_is_real "$g" || continue + java_shared_changed=true + break + fi if printf '%s\n' "$changed" | awk -v p="$g" 'index($0, p) == 1 { f = 1 } END { exit !f }'; then java_shared_changed=true break fi done fi + echo "java_shared_changed=$java_shared_changed" # Rows with ci_lane=docker-host own requires-docker (Testcontainers) # tests. They run directly on the GitHub host where a Docker daemon is diff --git a/tools/ci/bazel-lock-touches-java b/tools/ci/bazel-lock-touches-java new file mode 100755 index 000000000..72ea54b14 --- /dev/null +++ b/tools/ci/bazel-lock-touches-java @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Decide whether a MODULE.bazel.lock change can affect the Java build. + +Prints "true" or "false". Reads the two lock revisions from git. + + bazel-lock-touches-java + +MODULE.bazel.lock is in JAVA_SHARED_GLOBS, so any edit to it schedules all +seven Java rows. Those rows run on GitHub-hosted runners at max-parallel 4 and +are the long pole of a merge queue entry: on NVIDIA/nvcf#777, cloud-functions +alone took 19 minutes. That pull request's entire lock delta was one Rust +crate_universe extension, and the Java rows it scheduled rebuilt from near +scratch (168 remote cache hits out of 3073 actions) because a repin invalidates +external repository state. + +The lock is JSON, so this compares structure rather than matching text. A +grep for "maven" or "jvm" would be worse than useless here: Java artifacts are +pinned in maven_install.json, which is a separate entry in JAVA_SHARED_GLOBS, +and the lock's moduleExtensions carries no rules_jvm_external entry at all. So +such a grep would answer "not Java" for almost every input, including inputs +that genuinely do affect Java. + +Fail closed. Java is skipped only when every difference is positively +recognised as Java-irrelevant. Anything else, including a parse failure, a +missing file, an unreadable revision, a new top-level section, or an unfamiliar +module extension, answers "true". Wrongly skipping Java breaks main and is +found later by someone else; wrongly running it costs runner minutes. +""" + +import json +import subprocess +import sys + +LOCK = "MODULE.bazel.lock" + +# Module extensions whose state cannot reach a Java action. Keep this list +# small and evidence-based: an extension earns a place here only when it is +# clear it produces no input to a Java target. Anything absent is treated as +# Java-relevant, so forgetting to add one costs time, never correctness. +JAVA_IRRELEVANT_EXTENSIONS = { + "@@rules_rust+//crate_universe:extension.bzl%crate", + "@@rules_rust+//crate_universe/private:internal_extensions.bzl%cu_nr", +} + +# Top-level sections that may differ without implying a Java change, provided +# the differences inside them are themselves recognised. Every other top-level +# key differing means "true", so a future Bazel lock format that adds a section +# fails closed rather than being silently ignored. +INSPECTABLE_SECTIONS = {"moduleExtensions"} + +# dict.get() cannot tell an absent key from one whose value is null, and +# cannot tell an absent section from an empty one. Both conflations were live +# bugs: a new top-level section added as null compared equal to its own +# absence and was never noticed, and deleting moduleExtensions outright made +# the head look like an empty extension set, so the only "changed" extensions +# were the base's, all recognised, and the tool answered false. Losing a whole +# section must never be a skip. Compare against a sentinel instead. +MISSING = object() + + +def die_true(reason): + print("true") + print(f"[lock-scope] {reason}; running Java", file=sys.stderr) + raise SystemExit(0) + + +def read_lock(ref): + try: + raw = subprocess.run( + ["git", "show", f"{ref}:{LOCK}"], + capture_output=True, + check=True, + ).stdout + except (subprocess.CalledProcessError, OSError) as exc: + die_true(f"cannot read {LOCK} at {ref}: {exc}") + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + die_true(f"cannot parse {LOCK} at {ref}: {exc}") + # "[]" and "null" are valid JSON but not a lock. Without this the section + # comparison below raises on a non-mapping and the tool dies with a + # traceback, which is an exit code nobody reads as "run Java". + if not isinstance(parsed, dict): + die_true(f"{LOCK} at {ref} is {type(parsed).__name__}, not an object") + return parsed + + +def main(): + if len(sys.argv) != 3: + die_true("usage: bazel-lock-touches-java ") + base, head = sys.argv[1], sys.argv[2] + + a, b = read_lock(base), read_lock(head) + + if a == b: + print("false") + print("[lock-scope] lock is unchanged; skipping Java", file=sys.stderr) + return + + changed_sections = { + k for k in set(a) | set(b) if a.get(k, MISSING) != b.get(k, MISSING) + } + + unexpected = changed_sections - INSPECTABLE_SECTIONS + if unexpected: + die_true(f"lock sections changed that are not inspectable: {sorted(unexpected)}") + + # Require the section in both. Defaulting it to {} is what let a deletion + # read as "no extensions changed". + if "moduleExtensions" not in a or "moduleExtensions" not in b: + die_true("moduleExtensions is absent from one of the revisions") + ma, mb = a["moduleExtensions"], b["moduleExtensions"] + if not isinstance(ma, dict) or not isinstance(mb, dict): + die_true("moduleExtensions is not an object in one of the revisions") + changed_exts = { + k for k in set(ma) | set(mb) if ma.get(k, MISSING) != mb.get(k, MISSING) + } + + unrecognised = changed_exts - JAVA_IRRELEVANT_EXTENSIONS + if unrecognised: + die_true(f"module extensions changed that may affect Java: {sorted(unrecognised)}") + + print("false") + print( + f"[lock-scope] only Java-irrelevant extensions changed ({sorted(changed_exts)}); " + "skipping Java", + file=sys.stderr, + ) + + +if __name__ == "__main__": + main() diff --git a/tools/ci/test-bazel-lock-touches-java b/tools/ci/test-bazel-lock-touches-java new file mode 100755 index 000000000..9fc9214a1 --- /dev/null +++ b/tools/ci/test-bazel-lock-touches-java @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Behavioral test for tools/ci/bazel-lock-touches-java. +# +# The two answers are not symmetric. A wrong "true" costs runner minutes. A +# wrong "false" skips every Java row for a change that did affect Java, so a +# break reaches main and is found by whoever hits it next. Every case that +# cannot be positively recognised is therefore pinned to "true", and those +# cases outnumber the others here on purpose. +set -euo pipefail + +script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bazel-lock-touches-java" + +repo="$(mktemp -d)" +trap 'rm -rf "${repo}"' EXIT +cd "${repo}" +git init -q . +git config user.email t@example.com +git config user.name t + +fail=0 + +# Commit a lock and return the resulting revision. +commit_lock() { + printf '%s' "$1" > MODULE.bazel.lock + git add MODULE.bazel.lock + git commit -qm "$2" + git rev-parse HEAD +} + +check() { + local desc="$1" want="$2" base="$3" head="$4" + local got + got="$(python3 "${script}" "${base}" "${head}" 2>/dev/null || echo "CRASH")" + if [ "${got}" = "${want}" ]; then + printf 'ok %s\n' "${desc}" + else + printf 'FAIL %s: want %s, got %s\n' "${desc}" "${want}" "${got}" + fail=1 + fi +} + +RUST_A='{"lockFileVersion":26,"registryFileHashes":{"a":"1"},"selectedYankedVersions":{},"moduleExtensions":{"@@rules_rust+//crate_universe:extension.bzl%crate":{"v":1},"@@rules_oci+//oci:extensions.bzl%oci":{"v":1}},"facts":{"f":1}}' +RUST_B='{"lockFileVersion":26,"registryFileHashes":{"a":"1"},"selectedYankedVersions":{},"moduleExtensions":{"@@rules_rust+//crate_universe:extension.bzl%crate":{"v":2},"@@rules_oci+//oci:extensions.bzl%oci":{"v":1}},"facts":{"f":1}}' + +base=$(commit_lock "${RUST_A}" base) + +# The case this exists for: a Rust-only crate repin. +head=$(commit_lock "${RUST_B}" "rust repin") +check "rust crate_universe repin only" false "${base}" "${head}" + +# An identical lock is trivially irrelevant. Commit an unrelated file so the +# revision advances while the lock does not; committing the same lock content +# is a git no-op and would leave the ref empty rather than test anything. +echo unrelated > other.txt +git add other.txt +git commit -qm "unrelated change" +same=$(git rev-parse HEAD) +check "lock unchanged across commits" false "${head}" "${same}" + +# Every fixture below is RUST_B plus exactly one mutation, so each must be +# compared against RUST_B. Chaining them against the previous fixture would +# also revert the previous mutation, and a case could then pass on that +# reversion rather than on the condition it names. +baseline="${same}" + +# Anything that is not a recognised Rust extension must schedule Java. +OCI=$(python3 -c " +import json,sys +d=json.loads('''${RUST_B}''') +d['moduleExtensions']['@@rules_oci+//oci:extensions.bzl%oci']={'v':9} +print(json.dumps(d))") +h=$(commit_lock "${OCI}" "oci extension changed") +check "unrecognised extension changed" true "${baseline}" "${h}" + +# A registry hash change can move a Java module version. +REG=$(python3 -c " +import json +d=json.loads('''${RUST_B}''') +d['registryFileHashes']['b']='2' +print(json.dumps(d))") +h2=$(commit_lock "${REG}" "registry hash changed") +check "registryFileHashes changed" true "${baseline}" "${h2}" + +# A lock format bump affects everything. +VER=$(python3 -c " +import json +d=json.loads('''${RUST_B}''') +d['lockFileVersion']=27 +print(json.dumps(d))") +h3=$(commit_lock "${VER}" "lock version bump") +check "lockFileVersion changed" true "${baseline}" "${h3}" + +# A new top-level section must fail closed, not be ignored. +NEW=$(python3 -c " +import json +d=json.loads('''${RUST_B}''') +d['somethingBazelAddedLater']={'x':1} +print(json.dumps(d))") +h4=$(commit_lock "${NEW}" "new top-level section") +check "unknown top-level section" true "${baseline}" "${h4}" + +# yanked versions can change which module version resolves. +YNK=$(python3 -c " +import json +d=json.loads('''${RUST_B}''') +d['selectedYankedVersions']={'m':'1.0'} +print(json.dumps(d))") +h5=$(commit_lock "${YNK}" "yanked versions changed") +check "selectedYankedVersions changed" true "${baseline}" "${h5}" + +# Malformed or missing input must never read as "no Java change". +printf 'not json' > MODULE.bazel.lock +git add MODULE.bazel.lock && git commit -qm "corrupt lock" +bad=$(git rev-parse HEAD) +check "unparseable lock" true "${baseline}" "${bad}" +check "nonexistent revision" true "${baseline}" "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" +check "empty head revision" true "${baseline}" "" + +# check() always passes two arguments, so the argv-length branch needs its own +# invocation with a single one. Without this the "missing argument" case was +# really testing an unreadable revision. +argv_got="$(python3 "${script}" "${baseline}" 2>/dev/null || echo CRASH)" +if [ "${argv_got}" = "true" ]; then + printf 'ok %s\n' "too few arguments" +else + printf 'FAIL %s: want true, got %s\n' "too few arguments" "${argv_got}" + fail=1 +fi + +# Valid JSON that is not an object must fail closed rather than raise. +for bad_json in '[]' 'null' '"a string"' '42'; do + printf '%s' "${bad_json}" > MODULE.bazel.lock + git add MODULE.bazel.lock + git commit -qm "lock is ${bad_json}" + nb=$(git rev-parse HEAD) + check "lock is ${bad_json}, not an object" true "${baseline}" "${nb}" +done + +# A non-object moduleExtensions must fail closed too. +printf '%s' '{"lockFileVersion":26,"registryFileHashes":{"a":"1"},"selectedYankedVersions":{},"moduleExtensions":[],"facts":{"f":1}}' > MODULE.bazel.lock +git add MODULE.bazel.lock && git commit -qm "moduleExtensions is a list" +mex=$(git rev-parse HEAD) +check "moduleExtensions is not an object" true "${baseline}" "${mex}" + +# The real thing: NVIDIA/nvcf#777, base fcf08b63 vs head 96831eae. Fixtures are +# the parsed section digests rather than the 5 MB locks, but the shape is what +# the tool actually compares. Verified against the real files: the only +# difference is the crate_universe extension. +PR777_BASE='{"lockFileVersion":26,"registryFileHashes":{"h":"same"},"selectedYankedVersions":{},"moduleExtensions":{"@@rules_rust+//crate_universe:extension.bzl%crate":{"crates":"before"},"@@rules_python+//python/uv:uv.bzl%uv":{"v":1}},"facts":{"f":1}}' +PR777_HEAD='{"lockFileVersion":26,"registryFileHashes":{"h":"same"},"selectedYankedVersions":{},"moduleExtensions":{"@@rules_rust+//crate_universe:extension.bzl%crate":{"crates":"after"},"@@rules_python+//python/uv:uv.bzl%uv":{"v":1}},"facts":{"f":1}}' +pb=$(commit_lock "${PR777_BASE}" "pr777 base") +ph=$(commit_lock "${PR777_HEAD}" "pr777 head") +check "NVIDIA/nvcf#777 shape" false "${pb}" "${ph}" + +if [ "${fail}" -ne 0 ]; then + echo "bazel-lock-touches-java: FAILED" >&2 + exit 1 +fi +echo "bazel-lock-touches-java: all checks passed" diff --git a/tools/ci/test-bazel-lock-touches-java-properties b/tools/ci/test-bazel-lock-touches-java-properties new file mode 100755 index 000000000..ebcdb67c4 --- /dev/null +++ b/tools/ci/test-bazel-lock-touches-java-properties @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Property test for tools/ci/bazel-lock-touches-java. + +The example-based suite next to this file pins the cases we thought of. Two +rounds of review found real defects it did not cover, both in the same place: +comparing two parsed locks. dict.get() could not tell an absent key from a null +one, so a new top-level section added as null compared equal to its own +absence; and defaulting an absent moduleExtensions to {} made deleting the whole +section look like an empty extension set, so the tool answered "skip Java". + +Enumerating more examples would keep losing that race. This asserts the +invariant instead, over generated mutations, so the class of bug is closed +rather than two instances of it. + +Invariant: the tool may answer "false" only when the two locks differ +exclusively in the values of moduleExtensions entries whose keys are all +recognised as Java-irrelevant. Every other difference, of any shape, must +answer "true". + +The expected answer is derived here from the parsed structures directly, +independently of how the tool computes it, so agreement is evidence rather +than a restatement. +""" + +import itertools +import json +import pathlib +import random +import subprocess +import sys +import tempfile + +TOOL = pathlib.Path(__file__).resolve().parent / "bazel-lock-touches-java" + +IRRELEVANT = { + "@@rules_rust+//crate_universe:extension.bzl%crate", + "@@rules_rust+//crate_universe/private:internal_extensions.bzl%cu_nr", +} +OTHER_EXTS = [ + "@@rules_oci+//oci:extensions.bzl%oci", + "@@rules_python+//python/uv:uv.bzl%uv", + "@@rules_proto_grpc_java+//:module_extensions.bzl%download_plugins", +] +SECTIONS = ["lockFileVersion", "registryFileHashes", "selectedYankedVersions", "facts"] + +MISSING = object() + + +def base_lock(only_irrelevant=False): + """A base lock. only_irrelevant makes moduleExtensions contain nothing but + recognised keys, which is the shape that exposes mishandling of the section + itself: with an unrecognised key present, deleting or mistyping the section + is caught incidentally by that key showing up in the diff. Mutation testing + found this gap; without this variant the deletion bug survived 400 cases.""" + if only_irrelevant: + return { + "lockFileVersion": 26, + "registryFileHashes": {"h": "aaa"}, + "selectedYankedVersions": {}, + "moduleExtensions": { + "@@rules_rust+//crate_universe:extension.bzl%crate": {"v": 1}, + "@@rules_rust+//crate_universe/private:internal_extensions.bzl%cu_nr": {"v": 1}, + }, + "facts": {"f": 1}, + } + return { + "lockFileVersion": 26, + "registryFileHashes": {"h": "aaa"}, + "selectedYankedVersions": {}, + "moduleExtensions": { + "@@rules_rust+//crate_universe:extension.bzl%crate": {"v": 1}, + "@@rules_oci+//oci:extensions.bzl%oci": {"v": 1}, + }, + "facts": {"f": 1}, + } + + +def expected(a, b): + """Independently derive whether Java must run. True means run Java.""" + if not isinstance(a, dict) or not isinstance(b, dict): + return True + if a == b: + return False + keys = set(a) | set(b) + for k in keys: + if a.get(k, MISSING) == b.get(k, MISSING): + continue + if k != "moduleExtensions": + return True + if "moduleExtensions" not in a or "moduleExtensions" not in b: + return True + ma, mb = a["moduleExtensions"], b["moduleExtensions"] + if not isinstance(ma, dict) or not isinstance(mb, dict): + return True + for k in set(ma) | set(mb): + if ma.get(k, MISSING) == mb.get(k, MISSING): + continue + if k not in IRRELEVANT: + return True + return False + + +def mutate(rng, lock): + """Apply one random structural mutation.""" + d = json.loads(json.dumps(lock)) + op = rng.choice( + [ + "ext_value", "ext_add", "ext_del", "ext_null", + "sec_value", "sec_add", "sec_del", "sec_null", + "me_del", "me_type", "root_type", "noop", + ] + ) + me = d.get("moduleExtensions") + if op == "ext_value" and isinstance(me, dict) and me: + me[rng.choice(sorted(me))] = {"v": rng.randint(2, 99)} + elif op == "ext_add" and isinstance(me, dict): + me[rng.choice(sorted(IRRELEVANT) + OTHER_EXTS)] = {"v": rng.randint(2, 99)} + elif op == "ext_del" and isinstance(me, dict) and me: + del me[rng.choice(sorted(me))] + elif op == "ext_null" and isinstance(me, dict) and me: + me[rng.choice(sorted(me))] = None + elif op == "sec_value": + d[rng.choice(SECTIONS)] = {"changed": rng.randint(2, 99)} + elif op == "sec_add": + d[f"newSection{rng.randint(1, 3)}"] = rng.choice([None, {}, 1, "x"]) + elif op == "sec_del": + k = rng.choice(SECTIONS) + d.pop(k, None) + elif op == "sec_null": + d[rng.choice(SECTIONS)] = None + elif op == "me_del": + d.pop("moduleExtensions", None) + elif op == "me_type": + d["moduleExtensions"] = rng.choice([[], None, "x", 3]) + elif op == "root_type": + return rng.choice([[], None, "x", 3]) + return d + + +def run_tool(repo, base_sha, head_sha): + out = subprocess.run( + [sys.executable, str(TOOL), base_sha, head_sha], + cwd=repo, capture_output=True, text=True, + ) + return out.stdout.strip() + + +def git(repo, *args): + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +def main(): + rng = random.Random(20260818) + failures = [] + with tempfile.TemporaryDirectory() as repo: + git(repo, "init", "-q", ".") + git(repo, "config", "user.email", "t@example.com") + git(repo, "config", "user.name", "t") + p = pathlib.Path(repo, "MODULE.bazel.lock") + + cases = 0 + for i in range(400): + # Alternate the base so both extension shapes are exercised. + base = base_lock(only_irrelevant=(i % 2 == 1)) + p.write_text(json.dumps(base)) + git(repo, "add", "MODULE.bazel.lock") + subprocess.run( + ["git", "commit", "-qm", f"base {i}"], cwd=repo, capture_output=True + ) + base_sha = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True + ).stdout.strip() + head = base + for _ in range(rng.randint(1, 3)): + head = mutate(rng, head if isinstance(head, dict) else base_lock()) + p.write_text(json.dumps(head)) + git(repo, "add", "MODULE.bazel.lock") + subprocess.run( + ["git", "commit", "-qm", f"case {i}"], cwd=repo, capture_output=True + ) + head_sha = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True + ).stdout.strip() + + want = "true" if expected(base, head) else "false" + got = run_tool(repo, base_sha, head_sha) + cases += 1 + if got != want: + failures.append((i, want, got, json.dumps(head)[:300])) + if len(failures) >= 5: + break + + if failures: + print(f"property test FAILED after {cases} cases", file=sys.stderr) + for i, want, got, doc in failures: + print(f" case {i}: want {want}, got {got}\n head={doc}", file=sys.stderr) + return 1 + print(f"bazel-lock-touches-java properties: {cases} generated cases hold") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())