From c9932677c7e29c0eb941f8abfbc393f0e7001637 Mon Sep 17 00:00:00 2001 From: "Matt (Orion) Cook" Date: Thu, 30 Jul 2026 15:26:59 -0600 Subject: [PATCH 1/9] fix(cli): honor a caller-forwarded --target_pattern_file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner only suppressed its default target pattern for aspect's own `--target-pattern-file` arg. Bazel's own spelling arriving as `--bazel-flag=--target_pattern_file=` was invisible to it, so `ctx.args.targets` fell back to its default and Bazel rejected the invocation outright: ERROR: Command-line target pattern and --target_pattern_file cannot both be specified That is exactly the shape the IntelliJ Bazel plugin produces: the `tools/bazel` wrapper rewrites every Bazel-native flag to `--bazel-flag=`, so an IDE sync never reaches Bazel. Suppress the default when the caller forwarded the flag and gave no explicit patterns. Only the *default* is suppressed — explicit patterns still reach Bazel alongside the flag so Bazel emits the error above itself, rather than this runner growing a second spelling of it. The new `forwarded_flag_value` helper reads `ctx.args.bazel_flags` rather than the parsed rc because target patterns are resolved before `parse_rc` runs: the resolved pattern file has to be in the `base_flags` the rc is built from. `--target_pattern_file` is per-invocation and not rc material, so nothing is lost. --- .../aspect/private/lib/bazel_flags.axl | 31 ++++++++++++++++ .../aspect/private/lib/bazel_flags_test.axl | 36 +++++++++++++++++-- .../aspect/private/lib/bazel_runner.axl | 22 +++++++++++- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags.axl b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags.axl index a3a696f03..94f0ec0cf 100644 --- a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags.axl +++ b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags.axl @@ -44,6 +44,11 @@ also checked at runtime by `assert_ctx_bazel_ready_for_health_check` (see - `resolve_flags` — combine CLI bazel-flags + `BazelTrait` additions + task-time hooks + transform into a single command flag list. + - `forwarded_flag_value` — the value of a Bazel flag the caller forwarded + verbatim through `--bazel-flag==`. For the narrow case that + must be answered *before* the rc is parsed; everything else reads the + effective value off the `RunCommand` with `rc.flag_value`. + - `resolve_announce` / `resolve_bazel_announce` — resolve the `announce_bazel_*` flag values (`"auto"|"true"|"false"`) to bools; `auto` is on under CI. `resolve_bazel_announce` returns the @@ -151,6 +156,32 @@ def bazel_flag_args(build_phrase: str) -> dict: ), } +def forwarded_flag_value(bazel_flags: list, name: str) -> str: + """The value of a Bazel flag the caller forwarded verbatim through + `--bazel-flag==`, or `""` when unset. + + Last occurrence wins, mirroring Bazel's own single-valued option parsing (and + `RunCommand.flag_value`). Matches both spellings: `--name=value` in one + `--bazel-flag`, and the two-token `--name value` form split across + consecutive entries. The `name` must match exactly up to the `=`, so + `--target_pattern_file_foo=x` is not a `--target_pattern_file`. + + Reads `ctx.args.bazel_flags` rather than the parsed rc, so it does NOT see a + flag from `.bazelrc` or a `--config` expansion. That is deliberate: its one + caller resolves target patterns before `parse_rc` runs (the resolved pattern + file has to be in the `base_flags` the rc is parsed from), and the flags it + asks about are per-invocation ones no rc file should carry. Anything that can + wait for the rc should use `rc.flag_value` instead. + """ + eq_prefix = name + "=" + value = "" + for i, flag in enumerate(bazel_flags): + if flag.startswith(eq_prefix): + value = flag[len(eq_prefix):] + elif flag == name and i + 1 < len(bazel_flags) and not bazel_flags[i + 1].startswith("-"): + value = bazel_flags[i + 1] + return value + def resolve_startup_flags(ctx, bazel_trait): """Build the startup flag list: diff --git a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags_test.axl b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags_test.axl index 160859be8..519d4c801 100644 --- a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags_test.axl +++ b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags_test.axl @@ -4,7 +4,7 @@ Run with: aspect dev test-bazel-flags """ -load("./bazel_flags.axl", "announce_bazel_args", "aspect_endpoint_auth_flags", "bes_backend_auth_flags", "flags_delta", "remote_cache_auth_flags", "requested_config_names", "resolve_announce", "resolve_bazel_announce", "resolve_flags", "resolve_startup_flags", "setup_bazel_command", "sibling_rc") +load("./bazel_flags.axl", "announce_bazel_args", "aspect_endpoint_auth_flags", "bes_backend_auth_flags", "flags_delta", "forwarded_flag_value", "remote_cache_auth_flags", "requested_config_names", "resolve_announce", "resolve_bazel_announce", "resolve_flags", "resolve_startup_flags", "setup_bazel_command", "sibling_rc") def _eq(label, got, want): if got != want: @@ -195,6 +195,37 @@ def _test_requested_config_names(ctx): ["a", "b", "c"], ) +def _test_forwarded_flag_value(ctx): + """Reads a Bazel flag out of the caller's `--bazel-flag` list, last-wins. + + The case that matters: the `tools/bazel` wrapper rewrites every Bazel-native + flag to `--bazel-flag=`, so an IDE's `--target_pattern_file` arrives + here and the runner has to see it before the rc exists.""" + name = "--target_pattern_file" + + _eq("absent", forwarded_flag_value(["--keep_going"], name), "") + _eq("empty list", forwarded_flag_value([], name), "") + _eq("eq form", forwarded_flag_value(["--keep_going", name + "=/tmp/pat"], name), "/tmp/pat") + _eq( + "last occurrence wins", + forwarded_flag_value([name + "=/tmp/first", name + "=/tmp/second"], name), + "/tmp/second", + ) + + # The two-token spelling, split across consecutive `--bazel-flag` entries. + _eq("two-token form", forwarded_flag_value([name, "/tmp/pat"], name), "/tmp/pat") + _eq("two-token with no value left", forwarded_flag_value([name], name), "") + _eq("next token is a flag, not a value", forwarded_flag_value([name, "--keep_going"], name), "") + + # Prefix near-misses: matching must end at the `=`. + _eq("longer flag name", forwarded_flag_value([name + "_foo=/tmp/pat"], name), "") + _eq("bare longer flag name", forwarded_flag_value([name + "_foo", "/tmp/pat"], name), "") + + # An empty value is a real value ("unset the flag"), not an absent flag — + # but it reads back as `""`, so callers treat it as unset. Pinned so a + # future caller that cares knows the limit. + _eq("explicitly empty value", forwarded_flag_value([name + "="], name), "") + def _test_flags_delta(ctx): """`flags_delta` returns the multiset difference full − base in full's order. @@ -405,6 +436,7 @@ def _test_impl(ctx): _test_resolve_bazel_announce_maps_args_to_tuple(ctx) _test_announce_bazel_args_shape(ctx) _test_requested_config_names(ctx) + _test_forwarded_flag_value(ctx) _test_flags_delta(ctx) _test_remote_cache_auth_flags(ctx) _test_bes_backend_auth_flags(ctx) @@ -414,7 +446,7 @@ def _test_impl(ctx): # after the pure subtests prevents bleed-through. _test_setup_bazel_command_applies_to_ctx_bazel(ctx) _test_sibling_rc_transforms_startup(ctx) - print("bazel_flags_test.axl: OK (18 sections)") + print("bazel_flags_test.axl: OK (19 sections)") return 0 bazel_flags_tests = task( diff --git a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl index 0f8e40dea..75ae0e875 100644 --- a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl +++ b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl @@ -15,7 +15,7 @@ applies uniformly to both tasks. load("@aspect//bazel/build_events.axl", "announce_bes_results_url", "announce_bes_sinks", "announce_dropped_bes_sinks", "bes_streamed_by_bazel", "collect_bes_sinks", "dropped_bes_backends", "summarize_bes_upload") load("@aspect//bazel.axl", "BazelTrait", "DEFAULT_BAZEL_RETRY_ATTEMPTS") load("@std//time.axl", "sleep_iter") -load("./bazel_flags.axl", "aspect_endpoint_auth_flags", "resolve_bazel_announce") +load("./bazel_flags.axl", "aspect_endpoint_auth_flags", "forwarded_flag_value", "resolve_bazel_announce") load("./bazel_results.axl", "BES_DRAIN_TICK_MS", "MIN_HEARTBEAT_INTERVAL_MS", "archive_bazel_attempt", "collapse_repro_targets", "failed_labels", "failed_labels_by_subcommand", "init_data", "now_ms", "process_event", bazel_conclusion = "conclusion") load("./deployment_flags.axl", "advertised_results_url", "announce_deployment_flags", "bes_results_url_flag", "deployment_endpoint_flags") load("./health_check.axl", "HealthCheckTrait") @@ -266,6 +266,13 @@ def run_bazel_task(ctx: TaskContext, command: str, targets = None) -> TaskConclu `--target-pattern-file` (if the task declares it) by forwarding `--target_pattern_file=` to Bazel directly, and otherwise reads `ctx.args.targets`. + + A caller who forwards Bazel's own spelling + (`--bazel-flag=--target_pattern_file=`) is honored too: + the `ctx.args.targets` default is suppressed so Bazel doesn't + see a command-line pattern alongside the file. This is what + makes IDE / BSP tooling work through the `tools/bazel` wrapper, + which rewrites every Bazel-native flag to `--bazel-flag=`. """ hc_trait = ctx.traits[HealthCheckTrait] bazel_trait = ctx.traits[BazelTrait] @@ -294,6 +301,19 @@ def run_bazel_task(ctx: TaskContext, command: str, targets = None) -> TaskConclu fail("--target-pattern-file: file not found: " + pattern_file) base_flags.append("--target_pattern_file=" + pattern_file) targets = [] + elif not ctx.args.is_explicit("targets") and forwarded_flag_value(ctx.args.bazel_flags, "--target_pattern_file"): + # The caller forwarded Bazel's own `--target_pattern_file` instead of + # the aspect flag above — what the IntelliJ Bazel plugin does through + # the `tools/bazel` wrapper. The file supplies the patterns, so + # injecting the `targets` default would make Bazel reject the + # invocation ("Command-line target pattern and --target_pattern_file + # cannot both be specified"). Nothing to add to `base_flags`: the + # flag is already among the caller's `--bazel-flag`s. + # + # Only the *default* is suppressed. Explicit patterns still reach + # Bazel alongside the flag so Bazel emits that error itself, rather + # than this runner growing a second spelling of it. + targets = [] else: targets = ctx.args.targets From 10defd72777f316e4d725c2779859589db78e74a Mon Sep 17 00:00:00 2001 From: "Matt (Orion) Cook" Date: Thu, 30 Jul 2026 15:28:39 -0600 Subject: [PATCH 2/9] fix(bes): write the caller's --build_event_binary_file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Build::spawn` appends the CLI's own `--build_event_binary_file` after every user flag. Bazel's option is single-valued, so last-wins meant a caller who asked for a BEP file silently got nothing — the file was created by whoever made the temp path and never written to. The IntelliJ Bazel plugin drives sync on exactly that flag, so even with the target-pattern collision fixed it would parse an empty file. Collect a file sink for the caller's path instead of stripping or reordering flags. The CLI's path still wins on the command line; the caller's file is re-created from Bazel's own byte stream, since file sinks share the BES reader's raw-bytes path. Placed in `collect_bes_sinks` so all eight bazel-spawning tasks are covered — every one of them hit the same clobbering. Two consequences: `--build_event_binary_file_upload_mode` no longer governs that file (the caller's existing `sink.wait()` completes it, before the task concludes), and `--build_event_json_file` / `--build_event_text_file` are untouched — different flags, so Bazel still writes those itself. --- .../builtins/aspect/bazel/build_events.axl | 45 ++++++++++++++++++- .../aspect/bazel/build_events_test.axl | 45 ++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/crates/aspect-cli/src/builtins/aspect/bazel/build_events.axl b/crates/aspect-cli/src/builtins/aspect/bazel/build_events.axl index f873ba93f..93d8fd920 100644 --- a/crates/aspect-cli/src/builtins/aspect/bazel/build_events.axl +++ b/crates/aspect-cli/src/builtins/aspect/bazel/build_events.axl @@ -12,6 +12,11 @@ once rather than twice. Because that can leave no sink at all — and the Aspect Web UI link keys on an id only a sink mints — a task pairs the collect call with `bes_streamed_by_bazel`, which redirects the link to Bazel's invocation id. +`collect_bes_sinks` also covers the one Bazel-facing BEP flag the CLI would +otherwise clobber: a caller's `--build_event_binary_file` becomes a CLI file sink, +because `Build::spawn` appends the CLI's own path to that single-valued option +last and last wins. See `collect_bes_sinks` for what that changes. + The Aspect login JWT is attached to an Aspect-owned backend's sink metadata when the user has not supplied their own `authorization` header — see `aspect_endpoint_auth.axl` for the host gate and best-effort credential @@ -55,6 +60,22 @@ def bazel_bes_backend(rc, command: str) -> str: return "" return rc.flag_value("--bes_backend", command = command) or "" +def bazel_bep_file(rc, command: str) -> str: + """The `--build_event_binary_file` path the caller asked Bazel to write, or `""`. + + Reads through the run command, so it sees the flag wherever it came from — + `--bazel-flag=--build_event_binary_file=…`, a `.bazelrc`, or an expanded + `--config`. `rc` may be `None` for a caller with no run command to consult. + + Nobody but the caller sets this: the CLI's own BEP file is appended inside + `Build::spawn`, after rc expansion, and never passes through the rc. + + Public so `build_events_test.axl` can assert the resolution. + """ + if rc == None: + return "" + return rc.flag_value("--build_event_binary_file", command = command) or "" + def _drop_bazel_streamed(items: list, uri_of, bazel_backend: str) -> list: """`items` minus those whose `uri_of(item)` names the same endpoint as Bazel's own `bazel_backend`. @@ -106,16 +127,38 @@ def collect_bes_sinks(ctx, bazel_trait, rc, command: str = "build", extra_backen Sinks to the endpoint Bazel's own `--bes_backend` uploads to are dropped from both groups so the invocation is streamed once — see `_drop_bazel_streamed`. + Last comes a file sink for the caller's own `--build_event_binary_file`, when + they asked for one. Bazel cannot write two: the option is single-valued and + `Build::spawn` appends the CLI's path last, so the caller's file would + silently never be written. Writing it as a sink instead re-creates it from + Bazel's own byte stream — the file sinks share the BES reader's raw-bytes + path, so the result is what Bazel would have written. This is what lets IDE / + BSP tooling (the IntelliJ Bazel plugin) drive builds through the + `tools/bazel` wrapper and still get its BEP back. Two consequences worth + knowing: `--build_event_binary_file_upload_mode` no longer governs that file + (the caller's `wait()` on the returned sinks is what completes it, before the + task concludes), and `--build_event_json_file` / `--build_event_text_file` + are untouched — different flags, so Bazel still writes those itself. + The one call a bazel-spawning task makes; pair it with `bes_streamed_by_bazel` to keep the Aspect Web UI link resolvable. The caller `wait()`s the returned sinks after the build. """ bazel_backend = bazel_bes_backend(rc, command) - return ( + sinks = ( collect_bes_from_args(ctx, extra_backends = extra_backends, bazel_backend = bazel_backend) + _drop_bazel_streamed(list(bazel_trait.build_event_sinks), lambda s: s.uri, bazel_backend) ) + # Not routed through `_drop_bazel_streamed`: a file is a local dump, not a + # second upload to an endpoint Bazel already streams to. + bep_file = bazel_bep_file(rc, command) + if bep_file: + trace.event("bes.caller_bep_file", fields = {"path": bep_file, "command": command}) + sinks.append(bazel.build_events.file(path = bep_file)) + + return sinks + def dropped_bes_backends(ctx, bazel_trait, rc, command: str = "build", extra_backends = []) -> list[str]: """The BES endpoints `collect_bes_sinks` skipped because Bazel uploads to them itself, deduped. Same inputs as that call — pass the same arguments. diff --git a/crates/aspect-cli/src/builtins/aspect/bazel/build_events_test.axl b/crates/aspect-cli/src/builtins/aspect/bazel/build_events_test.axl index cd80bde00..e1853eeca 100644 --- a/crates/aspect-cli/src/builtins/aspect/bazel/build_events_test.axl +++ b/crates/aspect-cli/src/builtins/aspect/bazel/build_events_test.axl @@ -1,13 +1,14 @@ """Tests for `bazel/build_events.axl` — the BES upload summary line, the gRPC-sink filter shared by the announce/summary helpers, sink collection, and the duplicate-stream drop that keeps the CLI from streaming to an endpoint Bazel's -own `--bes_backend` already uploads to. +own `--bes_backend` already uploads to, and the file-sink tee that keeps a +caller's `--build_event_binary_file` from being silently clobbered. Run with: aspect dev test-bes-sinks """ -load("@aspect//bazel/build_events.axl", "bazel_bes_backend", "bes_results_line", "bes_streamed_by_bazel", "bes_upload_line", "collect_bes_from_args", "collect_bes_sinks", "dropped_bes_backends", "grpc_backends") +load("@aspect//bazel/build_events.axl", "bazel_bep_file", "bazel_bes_backend", "bes_results_line", "bes_streamed_by_bazel", "bes_upload_line", "collect_bes_from_args", "collect_bes_sinks", "dropped_bes_backends", "grpc_backends") def _eq(label, got, want): if got != want: @@ -248,6 +249,44 @@ def _test_collect_bes_sinks(_): [None, "grpcs://bes.other.example.com"], ) +def _test_bazel_bep_file(_): + """`bazel_bep_file` reads the caller's `--build_event_binary_file` off the run + command, so it sees the flag from `--bazel-flag=`, a `.bazelrc`, or a + `--config` expansion alike.""" + _eq("caller asked for one", bazel_bep_file(_fake_rc({"--build_event_binary_file": "/tmp/bep.binpb"}), "build"), "/tmp/bep.binpb") + _eq("flag unset", bazel_bep_file(_fake_rc({}), "build"), "") + _eq("no run command", bazel_bep_file(None, "build"), "") + +def _test_collect_bes_sinks_tees_caller_bep_file(_): + """A caller's `--build_event_binary_file` becomes a trailing file sink, so the + path they asked for is written from Bazel's own byte stream. + + Without it their file is silently empty: the Bazel option is single-valued and + `Build::spawn` appends the CLI's own path last.""" + trait = _fake_trait([struct(uri = "grpcs://bes.other.example.com")]) + + def uris(rc_values): + ctx = _fake_ctx(bes_backends = []) + return [s.uri for s in collect_bes_sinks(ctx, trait, _fake_rc(rc_values))] + + _eq( + "file sink appended after the trait's own", + uris({"--build_event_binary_file": "/tmp/bep.binpb"}), + ["grpcs://bes.other.example.com", None], + ) + _eq("no BEP flag, no extra sink", uris({}), ["grpcs://bes.other.example.com"]) + + # The tee is a local dump, so it survives the duplicate-stream drop that + # suppresses a CLI sink to the endpoint Bazel itself uploads to. + duplicate = "grpcs://bes.acme.aspect.build" + ctx = _fake_ctx(bes_backends = [duplicate]) + rc = _fake_rc({"--bes_backend": duplicate, "--build_event_binary_file": "/tmp/bep.binpb"}) + _eq( + "kept while every gRPC sink is suppressed", + [s.uri for s in collect_bes_sinks(ctx, _fake_trait([struct(uri = duplicate)]), rc)], + [None], + ) + def _test_bes_streamed_by_bazel(_): """True only when Bazel's `--bes_backend` is the runner's own Aspect BES backend — the case where the Web UI still holds the invocation (under @@ -280,6 +319,8 @@ _UNIT_TESTS = [ _test_bazel_bes_backend, _test_collect_drops_duplicate_backends, _test_collect_bes_sinks, + _test_bazel_bep_file, + _test_collect_bes_sinks_tees_caller_bep_file, _test_bes_streamed_by_bazel, ] From d9f7f2a424f14d14c27ef645ceeb081be0ae9950 Mon Sep 17 00:00:00 2001 From: "Matt (Orion) Cook" Date: Tue, 4 Aug 2026 13:40:39 -0600 Subject: [PATCH 3/9] fix(tools): route `bazel config` to vanilla bazel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BAZEL_VERBS` was generated from `bazel help`'s "Available commands" list, which hides `config`. A verb missing from that table is treated as a custom aspect task, so `bazel config` became `aspect config` → `error: unrecognized subcommand 'config'`. The IntelliJ Bazel plugin calls `bazel config --dump_all --output=json` during sync and parses stdout as JSON, so this failed the sync outright. Regenerate from `bazel help completion`'s `BAZEL_COMMAND_LIST`, which includes hidden commands — verified against Bazel 9.0.1, `config` is the sole difference. Record the regeneration recipe and *why* it must not be `bazel help` in the comment, and in tools/bazel.md. The gap also silently degraded the pre-verb disambiguation walk, since KNOWN_VERBS_STR is built from the same table. --- tools/bazel | 20 +++++++++++++++----- tools/bazel.md | 6 +++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/tools/bazel b/tools/bazel index a6adacba5..baf0f3f88 100755 --- a/tools/bazel +++ b/tools/bazel @@ -161,12 +161,22 @@ ASPECT_VERBS_WITH_BAZEL_FLAGS=( # The closed set of Bazel commands (Bazel 9). A verb here that is NOT in # ASPECT_VERBS_WITH_BAZEL_FLAGS forwards to vanilla bazel untouched (query, -# info, clean, mod, …). Generated by: `bazel help` (the "Available commands" -# list). +# info, clean, mod, …). +# +# Regenerate with: +# bazel help completion | sed -n 's/^BAZEL_COMMAND_LIST="\(.*\)"$/\1/p' +# +# Use `bazel help completion`, NOT `bazel help`: the latter's "Available +# commands" list omits hidden commands, and a verb missing from this table is +# routed to `aspect` instead of bazel. That is how `config` went missing — the +# IntelliJ Bazel plugin calls `bazel config --dump_all --output=json` during +# sync and got `error: unrecognized subcommand 'config'`. A gap here also +# silently degrades the pre-verb disambiguation walk, since KNOWN_VERBS_STR is +# built from this table. BAZEL_VERBS=( - aquery build canonicalize-flags clean coverage cquery dump fetch help - info license mobile-install mod print_action query run shutdown test - vendor version + aquery build canonicalize-flags clean config coverage cquery dump fetch + help info license mobile-install mod print_action query run shutdown + test vendor version ) # Bazel flags that take a SPACE-separated value (e.g. `--config foo` rather diff --git a/tools/bazel.md b/tools/bazel.md index c03231814..0d6bac1bd 100644 --- a/tools/bazel.md +++ b/tools/bazel.md @@ -151,7 +151,11 @@ The fix: aspect sets `ASPECT_CLI_RUNNING=1` on every child `bazel` it spawns. `t Two lists at the top of the script drive every routing decision; edit them in your repo copy: - `ASPECT_VERBS_WITH_BAZEL_FLAGS` — verbs routed to `aspect` with bazel-flag rewriting. Default: `build buildifier delivery format gazelle lint test`. Add your own bazel-flag-aware aspect commands (e.g. a custom task that shells out to bazel) — including `run`, once you're ready for `aspect run` to shadow `bazel run` in your workspace. (`ASPECT_WRAPPER_SKIP=1` bypasses this entirely — everything goes to vanilla bazel.) -- `BAZEL_VERBS` — the closed set of Bazel commands. A verb here that's *not* in the list above forwards to vanilla bazel. A verb in *neither* list is treated as a custom aspect task and routed to aspect verbatim. Update this only if Bazel adds a command. +- `BAZEL_VERBS` — the closed set of Bazel commands. A verb here that's *not* in the list above forwards to vanilla bazel. A verb in *neither* list is treated as a custom aspect task and routed to aspect verbatim. Update this only if Bazel adds a command, and regenerate it from `bazel help completion`'s `BAZEL_COMMAND_LIST` rather than `bazel help` — the latter hides some commands (`config`), and a hidden command missing here gets misrouted to `aspect`: + + ``` + bazel help completion | sed -n 's/^BAZEL_COMMAND_LIST="\(.*\)"$/\1/p' + ``` Plus the embedded Bazel flag lists (`BAZEL_VALUE_FLAGS`, `BAZEL_BOOL_FLAGS`, `BAZEL_SHORT_VALUE_FLAGS`, `BAZEL_SHORT_BOOL_FLAGS`) covered above. There is no aspect-flag list — anything not recognized as a Bazel flag passes through to aspect. From f0e6f6cfa78599fef3da346eba4b1255b38a9a87 Mon Sep 17 00:00:00 2001 From: "Matt (Orion) Cook" Date: Thu, 30 Jul 2026 15:29:54 -0600 Subject: [PATCH 4/9] ci: smoke-test the IDE/BSP invocation shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit tests cover the two resolvers, but nothing exercised the pair end to end: that the composed bazel command line is one Bazel accepts, and that the caller's BEP file actually lands on disk. Extend the existing `test-flags-task` step in both pipelines — it already writes the pattern file — with one `aspect build` in the shape the IntelliJ Bazel plugin produces (Bazel's own flag spellings behind `--bazel-flag`), asserting a non-empty BEP at the caller's path. --- .buildkite/pipeline.yaml | 18 ++++++++++++++++-- .github/workflows/ci-workflows.yaml | 18 ++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml index 905926452..a8e4b1685 100644 --- a/.buildkite/pipeline.yaml +++ b/.buildkite/pipeline.yaml @@ -329,7 +329,12 @@ steps: aspect test --task:name test-bk # Smoke-tests the new test-task flags. `--target-pattern-file` is forwarded # to Bazel verbatim, so the assertion is just that aspect resolves the file - # and the run completes. `--coverage` is exercised against an sh_test rather + # and the run completes. The IDE/BSP step re-runs the same pattern file in the + # shape the IntelliJ Bazel plugin produces through the tools/bazel wrapper — + # Bazel's own flag spellings behind `--bazel-flag` — and asserts the caller's + # BEP file is actually written. The CLI appends its own + # `--build_event_binary_file` last, and the Bazel option is single-valued. + # `--coverage` is exercised against an sh_test rather # than a rust_test: `toolchains_llvm_bootstrapped` 0.5.2 (pinned in # MODULE.bazel) ships without `libclang_rt.profile.a`, so any rust_test under # --collect_code_coverage fails CppLink on libld/libc shared libs (upstream @@ -356,7 +361,16 @@ steps: echo "# smoke: targets forwarded via --target_pattern_file" > $$PATTERNS echo "//examples/test_states:always_pass" >> $$PATTERNS aspect test --task:name test-bk-target-pattern-file --target-pattern-file=$$PATTERNS - rm -f $$PATTERNS + + echo "--- :aspect: IDE/BSP shape — forwarded --target_pattern_file + --build_event_binary_file" + BEP=$$(mktemp) + aspect build --task:name test-bk-ide-passthrough \ + --bazel-flag=--target_pattern_file=$$PATTERNS \ + --bazel-flag=--build_event_binary_file=$$BEP \ + --bazel-flag=--build_event_binary_file_upload_mode=wait_for_upload_complete \ + --bazel-flag=--tool_tag=bazelbsp:3.2.0 + test -s $$BEP || { echo "caller's --build_event_binary_file was not written"; exit 1; } + rm -f $$BEP $$PATTERNS echo "--- :aspect: aspect test --coverage (+ --coverage-report + --coverage-tool)" REPORT=$$(mktemp) diff --git a/.github/workflows/ci-workflows.yaml b/.github/workflows/ci-workflows.yaml index b6c780195..021ae6a7c 100644 --- a/.github/workflows/ci-workflows.yaml +++ b/.github/workflows/ci-workflows.yaml @@ -613,7 +613,12 @@ jobs: # Smoke-tests the new test-task flags. Ported from the Buildkite # `test-flags-task` step. `--target-pattern-file` is forwarded to Bazel # verbatim, so the assertion is just that aspect resolves the file and the run - # completes. `--coverage` is exercised against an sh_test rather than a + # completes. The IDE/BSP step re-runs the same pattern file in the shape the + # IntelliJ Bazel plugin produces through the tools/bazel wrapper — Bazel's own + # flag spellings behind `--bazel-flag` — and asserts the caller's BEP file is + # actually written. The CLI appends its own `--build_event_binary_file` last, + # and the Bazel option is single-valued. + # `--coverage` is exercised against an sh_test rather than a # rust_test: toolchains_llvm_bootstrapped 0.5.2 (pinned in MODULE.bazel) ships # without libclang_rt.profile.a, so any rust_test under --collect_code_coverage # fails CppLink (upstream hermeticbuild/hermetic-llvm#318, fixed in #468 — not @@ -636,7 +641,16 @@ jobs: echo "# smoke: targets forwarded via --target_pattern_file" > "$PATTERNS" echo "//examples/test_states:always_pass" >> "$PATTERNS" aspect test --task:name test-gha-target-pattern-file --target-pattern-file="$PATTERNS" - rm -f "$PATTERNS" + + echo "--- IDE/BSP shape — forwarded --target_pattern_file + --build_event_binary_file" + BEP=$(mktemp) + aspect build --task:name test-gha-ide-passthrough \ + --bazel-flag=--target_pattern_file="$PATTERNS" \ + --bazel-flag=--build_event_binary_file="$BEP" \ + --bazel-flag=--build_event_binary_file_upload_mode=wait_for_upload_complete \ + --bazel-flag=--tool_tag=bazelbsp:3.2.0 + test -s "$BEP" || { echo "caller's --build_event_binary_file was not written"; exit 1; } + rm -f "$BEP" "$PATTERNS" echo "--- aspect test --coverage (+ --coverage-report + --coverage-tool)" REPORT=$(mktemp) From 3aa0c79559da1a39d2ed1a8de7d36cccac6fcc68 Mon Sep 17 00:00:00 2001 From: "Matt (Orion) Cook" Date: Tue, 4 Aug 2026 13:40:58 -0600 Subject: [PATCH 5/9] chore: ignore .idea/ and .bazelbsp/ Both are generated: .idea/ by the IDE, .bazelbsp/ by the JetBrains Bazel plugin (its injected aspect .bzl files), and both showed as untracked after an IntelliJ sync. --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 391c8a540..71e0e9c6c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,8 @@ site # macOS desktop services files .DS_Store -.claude \ No newline at end of file +.claude + +# IDE project state and the JetBrains Bazel plugin's generated aspect files +.idea/ +.bazelbsp/ \ No newline at end of file From 2af86d1ca78e71b43b9a1cfc13545c87e2ebe8d3 Mon Sep 17 00:00:00 2001 From: "Matt (Orion) Cook" Date: Tue, 4 Aug 2026 15:57:54 -0600 Subject: [PATCH 6/9] Revert "fix(cli): honor a caller-forwarded --target_pattern_file" This reverts commit c9932677c7e29c0eb941f8abfbc393f0e7001637. --- .../aspect/private/lib/bazel_flags.axl | 31 ---------------- .../aspect/private/lib/bazel_flags_test.axl | 36 ++----------------- .../aspect/private/lib/bazel_runner.axl | 22 +----------- 3 files changed, 3 insertions(+), 86 deletions(-) diff --git a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags.axl b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags.axl index 94f0ec0cf..a3a696f03 100644 --- a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags.axl +++ b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags.axl @@ -44,11 +44,6 @@ also checked at runtime by `assert_ctx_bazel_ready_for_health_check` (see - `resolve_flags` — combine CLI bazel-flags + `BazelTrait` additions + task-time hooks + transform into a single command flag list. - - `forwarded_flag_value` — the value of a Bazel flag the caller forwarded - verbatim through `--bazel-flag==`. For the narrow case that - must be answered *before* the rc is parsed; everything else reads the - effective value off the `RunCommand` with `rc.flag_value`. - - `resolve_announce` / `resolve_bazel_announce` — resolve the `announce_bazel_*` flag values (`"auto"|"true"|"false"`) to bools; `auto` is on under CI. `resolve_bazel_announce` returns the @@ -156,32 +151,6 @@ def bazel_flag_args(build_phrase: str) -> dict: ), } -def forwarded_flag_value(bazel_flags: list, name: str) -> str: - """The value of a Bazel flag the caller forwarded verbatim through - `--bazel-flag==`, or `""` when unset. - - Last occurrence wins, mirroring Bazel's own single-valued option parsing (and - `RunCommand.flag_value`). Matches both spellings: `--name=value` in one - `--bazel-flag`, and the two-token `--name value` form split across - consecutive entries. The `name` must match exactly up to the `=`, so - `--target_pattern_file_foo=x` is not a `--target_pattern_file`. - - Reads `ctx.args.bazel_flags` rather than the parsed rc, so it does NOT see a - flag from `.bazelrc` or a `--config` expansion. That is deliberate: its one - caller resolves target patterns before `parse_rc` runs (the resolved pattern - file has to be in the `base_flags` the rc is parsed from), and the flags it - asks about are per-invocation ones no rc file should carry. Anything that can - wait for the rc should use `rc.flag_value` instead. - """ - eq_prefix = name + "=" - value = "" - for i, flag in enumerate(bazel_flags): - if flag.startswith(eq_prefix): - value = flag[len(eq_prefix):] - elif flag == name and i + 1 < len(bazel_flags) and not bazel_flags[i + 1].startswith("-"): - value = bazel_flags[i + 1] - return value - def resolve_startup_flags(ctx, bazel_trait): """Build the startup flag list: diff --git a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags_test.axl b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags_test.axl index 519d4c801..160859be8 100644 --- a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags_test.axl +++ b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_flags_test.axl @@ -4,7 +4,7 @@ Run with: aspect dev test-bazel-flags """ -load("./bazel_flags.axl", "announce_bazel_args", "aspect_endpoint_auth_flags", "bes_backend_auth_flags", "flags_delta", "forwarded_flag_value", "remote_cache_auth_flags", "requested_config_names", "resolve_announce", "resolve_bazel_announce", "resolve_flags", "resolve_startup_flags", "setup_bazel_command", "sibling_rc") +load("./bazel_flags.axl", "announce_bazel_args", "aspect_endpoint_auth_flags", "bes_backend_auth_flags", "flags_delta", "remote_cache_auth_flags", "requested_config_names", "resolve_announce", "resolve_bazel_announce", "resolve_flags", "resolve_startup_flags", "setup_bazel_command", "sibling_rc") def _eq(label, got, want): if got != want: @@ -195,37 +195,6 @@ def _test_requested_config_names(ctx): ["a", "b", "c"], ) -def _test_forwarded_flag_value(ctx): - """Reads a Bazel flag out of the caller's `--bazel-flag` list, last-wins. - - The case that matters: the `tools/bazel` wrapper rewrites every Bazel-native - flag to `--bazel-flag=`, so an IDE's `--target_pattern_file` arrives - here and the runner has to see it before the rc exists.""" - name = "--target_pattern_file" - - _eq("absent", forwarded_flag_value(["--keep_going"], name), "") - _eq("empty list", forwarded_flag_value([], name), "") - _eq("eq form", forwarded_flag_value(["--keep_going", name + "=/tmp/pat"], name), "/tmp/pat") - _eq( - "last occurrence wins", - forwarded_flag_value([name + "=/tmp/first", name + "=/tmp/second"], name), - "/tmp/second", - ) - - # The two-token spelling, split across consecutive `--bazel-flag` entries. - _eq("two-token form", forwarded_flag_value([name, "/tmp/pat"], name), "/tmp/pat") - _eq("two-token with no value left", forwarded_flag_value([name], name), "") - _eq("next token is a flag, not a value", forwarded_flag_value([name, "--keep_going"], name), "") - - # Prefix near-misses: matching must end at the `=`. - _eq("longer flag name", forwarded_flag_value([name + "_foo=/tmp/pat"], name), "") - _eq("bare longer flag name", forwarded_flag_value([name + "_foo", "/tmp/pat"], name), "") - - # An empty value is a real value ("unset the flag"), not an absent flag — - # but it reads back as `""`, so callers treat it as unset. Pinned so a - # future caller that cares knows the limit. - _eq("explicitly empty value", forwarded_flag_value([name + "="], name), "") - def _test_flags_delta(ctx): """`flags_delta` returns the multiset difference full − base in full's order. @@ -436,7 +405,6 @@ def _test_impl(ctx): _test_resolve_bazel_announce_maps_args_to_tuple(ctx) _test_announce_bazel_args_shape(ctx) _test_requested_config_names(ctx) - _test_forwarded_flag_value(ctx) _test_flags_delta(ctx) _test_remote_cache_auth_flags(ctx) _test_bes_backend_auth_flags(ctx) @@ -446,7 +414,7 @@ def _test_impl(ctx): # after the pure subtests prevents bleed-through. _test_setup_bazel_command_applies_to_ctx_bazel(ctx) _test_sibling_rc_transforms_startup(ctx) - print("bazel_flags_test.axl: OK (19 sections)") + print("bazel_flags_test.axl: OK (18 sections)") return 0 bazel_flags_tests = task( diff --git a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl index 75ae0e875..0f8e40dea 100644 --- a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl +++ b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl @@ -15,7 +15,7 @@ applies uniformly to both tasks. load("@aspect//bazel/build_events.axl", "announce_bes_results_url", "announce_bes_sinks", "announce_dropped_bes_sinks", "bes_streamed_by_bazel", "collect_bes_sinks", "dropped_bes_backends", "summarize_bes_upload") load("@aspect//bazel.axl", "BazelTrait", "DEFAULT_BAZEL_RETRY_ATTEMPTS") load("@std//time.axl", "sleep_iter") -load("./bazel_flags.axl", "aspect_endpoint_auth_flags", "forwarded_flag_value", "resolve_bazel_announce") +load("./bazel_flags.axl", "aspect_endpoint_auth_flags", "resolve_bazel_announce") load("./bazel_results.axl", "BES_DRAIN_TICK_MS", "MIN_HEARTBEAT_INTERVAL_MS", "archive_bazel_attempt", "collapse_repro_targets", "failed_labels", "failed_labels_by_subcommand", "init_data", "now_ms", "process_event", bazel_conclusion = "conclusion") load("./deployment_flags.axl", "advertised_results_url", "announce_deployment_flags", "bes_results_url_flag", "deployment_endpoint_flags") load("./health_check.axl", "HealthCheckTrait") @@ -266,13 +266,6 @@ def run_bazel_task(ctx: TaskContext, command: str, targets = None) -> TaskConclu `--target-pattern-file` (if the task declares it) by forwarding `--target_pattern_file=` to Bazel directly, and otherwise reads `ctx.args.targets`. - - A caller who forwards Bazel's own spelling - (`--bazel-flag=--target_pattern_file=`) is honored too: - the `ctx.args.targets` default is suppressed so Bazel doesn't - see a command-line pattern alongside the file. This is what - makes IDE / BSP tooling work through the `tools/bazel` wrapper, - which rewrites every Bazel-native flag to `--bazel-flag=`. """ hc_trait = ctx.traits[HealthCheckTrait] bazel_trait = ctx.traits[BazelTrait] @@ -301,19 +294,6 @@ def run_bazel_task(ctx: TaskContext, command: str, targets = None) -> TaskConclu fail("--target-pattern-file: file not found: " + pattern_file) base_flags.append("--target_pattern_file=" + pattern_file) targets = [] - elif not ctx.args.is_explicit("targets") and forwarded_flag_value(ctx.args.bazel_flags, "--target_pattern_file"): - # The caller forwarded Bazel's own `--target_pattern_file` instead of - # the aspect flag above — what the IntelliJ Bazel plugin does through - # the `tools/bazel` wrapper. The file supplies the patterns, so - # injecting the `targets` default would make Bazel reject the - # invocation ("Command-line target pattern and --target_pattern_file - # cannot both be specified"). Nothing to add to `base_flags`: the - # flag is already among the caller's `--bazel-flag`s. - # - # Only the *default* is suppressed. Explicit patterns still reach - # Bazel alongside the flag so Bazel emits that error itself, rather - # than this runner growing a second spelling of it. - targets = [] else: targets = ctx.args.targets From f7876f0e2318e70b77b4fbee9d092bad6749ceaf Mon Sep 17 00:00:00 2001 From: "Matt (Orion) Cook" Date: Tue, 4 Aug 2026 16:42:35 -0600 Subject: [PATCH 7/9] fix(cli): resolve target patterns from the effective flag set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run_bazel_task` only suppressed its target-pattern default for aspect's own `--target-pattern-file` arg. Bazel's spelling reaching aspect as `--bazel-flag=--target_pattern_file=` — what `tools/bazel` produces for every Bazel-native flag, and so what an IntelliJ BSP sync sends — lands in `ctx.args.bazel_flags` instead, invisible there. `ctx.args.targets` then fell back to its declared default `["..."]`, which reaches Bazel as residue alongside the forwarded flag: ERROR: Command-line target pattern and --target_pattern_file cannot both be specified That pair has no last-wins rule in Bazel — it is a hard conflict. So the only decision this runner owns is whether to invent a pattern the user never typed; every precedence question belongs to Bazel's own option parser. A `RunCommand` is the *effective* option set, not just the rc file — CLI flags live in it as the `` source — so `rc.flag_value("--target_pattern_file", ...)` answers for the forwarded spelling with the same last-wins / `=`-form / two-token matching as `crates/bazelrc`. Reaching it only needed the ordering fixed: aspect's own `--target-pattern-file` moves from `base_flags` (an *input* to `parse_rc`) to the per-invocation `flags`, freeing patterns to resolve after the rc parse. That also keeps the flag out of the rc's `always` bucket, so it is no longer expanded for every command. Explicit patterns are always forwarded, so Bazel emits the error above itself and the runner's second spelling of it is dropped. The existence check on `--target-pattern-file` stays, since aspect resolves that path. Supersedes the reverted `forwarded_flag_value` scan (c9932677 / 2af86d1c), which reimplemented Bazel's option parsing in AXL. --- .aspect/config.axl | 3 +- .../aspect/private/lib/bazel_runner.axl | 77 +++++++++++++++---- .../aspect/private/lib/bazel_runner_test.axl | 61 ++++++++++++++- 3 files changed, 125 insertions(+), 16 deletions(-) diff --git a/.aspect/config.axl b/.aspect/config.axl index c1ff2b65e..bef5194bd 100644 --- a/.aspect/config.axl +++ b/.aspect/config.axl @@ -361,7 +361,8 @@ def config(ctx: ConfigContext): # Run with: aspect dev test-bazel-flags ctx.tasks.add(bazel_flags_tests) - # bazel_runner.axl dispatch helpers: the bazel_attempt_end/build_end seam. + # bazel_runner.axl: the bazel_attempt_end/build_end dispatch seam, plus + # target_patterns (when the `targets` default is suppressed). # Run with: aspect dev test-bazel-runner ctx.tasks.add(bazel_runner_tests) diff --git a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl index 0f8e40dea..0d9ccde47 100644 --- a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl +++ b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl @@ -244,6 +244,37 @@ def dispatch_bazel_build_end(ctx, bazel_trait, exit_code): for handler in bazel_trait.build_end: handler(ctx, exit_code) +def target_patterns(explicit: bool, cli_targets: list[str], own_pattern_file: str, rc, command: str) -> list[str]: + """The target patterns to place on Bazel's command line. + + Bazel rejects an invocation carrying both command-line patterns and + `--target_pattern_file` — that pair has no last-wins rule — so the single + decision here is whether to materialize the `targets` arg's declared + default (`["..."]`), a pattern the user never typed. Explicit patterns + always pass through: when they conflict with a pattern file, Bazel reports + it, and this runner does not grow a second spelling of that error. + + A pattern file counts no matter which spelling delivered it: aspect's own + `--target-pattern-file` arrives as `own_pattern_file`, while Bazel's + `--target_pattern_file` reaches `rc` — from `--bazel-flag`, `.bazelrc`, or + a `--config` expansion alike, resolved with Bazel's own last-wins parsing. + + Args: + explicit: whether the user actually passed target patterns + (`ctx.args.is_explicit("targets")`) rather than + inheriting the arg's default. + cli_targets: `ctx.args.targets` — never empty (the arg declares + `default = ["..."]`, `minimum = 1`). + own_pattern_file: value of aspect's `--target-pattern-file`, or `""`. + rc: the active `RunCommand`; only `flag_value` is used. + command: Bazel subcommand whose options `rc` resolves. + """ + if explicit: + return cli_targets + if own_pattern_file or rc.flag_value("--target_pattern_file", command = command): + return [] + return cli_targets + def run_bazel_task(ctx: TaskContext, command: str, targets = None) -> TaskConclusion: """Shared impl for build/test tasks. @@ -284,18 +315,14 @@ def run_bazel_task(ctx: TaskContext, command: str, targets = None) -> TaskConclu deployment = deployment_endpoint_flags(ctx) base_flags.extend(deployment.base_flags) - if targets == None: - # --target-pattern-file is only honored when the task declares it. - pattern_file = getattr(ctx.args, "target_pattern_file", "") if hasattr(ctx.args, "target_pattern_file") else "" - if pattern_file: - if ctx.args.is_explicit("targets"): - fail("--target-pattern-file cannot be combined with command-line target patterns") - if not ctx.std.fs.exists(pattern_file): - fail("--target-pattern-file: file not found: " + pattern_file) - base_flags.append("--target_pattern_file=" + pattern_file) - targets = [] - else: - targets = ctx.args.targets + # Patterns resolve after the rc parse below, so `setup` opens with what the + # user stated and the spawn refines it to the resolved list. + if targets != None: + subject = " ".join(targets) + elif ctx.args.is_explicit("targets"): + subject = " ".join(ctx.args.targets) + else: + subject = "" data = init_data() @@ -303,7 +330,7 @@ def run_bazel_task(ctx: TaskContext, command: str, targets = None) -> TaskConclu # parse + `use_rc`, health checks. The active run command drives the # build/test below (a failed health check concludes the surface and fails # the task inside setup_phase). - rc = setup_phase(ctx, lifecycle, " ".join(targets), "bazel_results", data, hc_trait, bazel_trait, command, bazel_base_flags = base_flags) + rc = setup_phase(ctx, lifecycle, subject, "bazel_results", data, hc_trait, bazel_trait, command, bazel_base_flags = base_flags) announce_version, announce_command = resolve_bazel_announce(ctx) # Announced with the first spawn below (the streams belong to the spawn) and @@ -325,6 +352,26 @@ def run_bazel_task(ctx: TaskContext, command: str, targets = None) -> TaskConclu invocation_flags = aspect_endpoint_auth_flags(ctx, rc, command) invocation_flags.extend(bes_results_url_flag(ctx, rc, bes_sinks, deployment, command)) + if targets == None: + # aspect's own `--target-pattern-file` (declared by build/test only) is a + # per-invocation flag, not rc material — and keeping it out of the parsed + # rc is what lets `target_patterns` ask `rc` about the forwarded + # spelling. Forwarded rather than expanded into argv, since the flag + # exists to bypass OS command-line length limits. + own_pattern_file = getattr(ctx.args, "target_pattern_file", "") if hasattr(ctx.args, "target_pattern_file") else "" + if own_pattern_file: + if not ctx.std.fs.exists(own_pattern_file): + fail("--target-pattern-file: file not found: " + own_pattern_file) + invocation_flags.append("--target_pattern_file=" + own_pattern_file) + + targets = target_patterns( + ctx.args.is_explicit("targets"), + ctx.args.targets, + own_pattern_file, + rc, + command, + ) + # The same viewer reaches the CI surfaces through `data` — they key the link on the # sink's own id rather than reading it off the command line. A property of the # resolved flags, so it is restored onto each retry's fresh `data`. @@ -395,6 +442,10 @@ def run_bazel_task(ctx: TaskContext, command: str, targets = None) -> TaskConclu # work, just attempted again. emoji = "🧪" if command == "test" else "🔨", ), + # The resolved patterns are only known after the rc parse, so the + # `setup` surface opened without them; name them here. A pattern-file + # run resolves to no patterns, and `""` means "no change". + subject = " ".join(targets), ) # Disclose what this bazel call was wired with, after the spawn phase diff --git a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner_test.axl b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner_test.axl index a29711338..34abda876 100644 --- a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner_test.axl +++ b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner_test.axl @@ -6,11 +6,16 @@ bazel-driving task calls after `build.wait()` / `test.wait()` to fire helpers are short, but they're the contract every task depends on — silent breakage here would skip every Workflows-feature recovery hook. +Also covers `target_patterns`, which decides whether the `targets` arg's +default reaches Bazel — get that wrong and a caller-forwarded +`--target_pattern_file` collides with an invented pattern, which Bazel +rejects outright. + Run with: aspect dev test-bazel-runner """ -load("./bazel_runner.axl", "dispatch_bazel_attempt_end", "dispatch_bazel_build_end") +load("./bazel_runner.axl", "dispatch_bazel_attempt_end", "dispatch_bazel_build_end", "target_patterns") def _eq(label, got, want): if got != want: @@ -84,13 +89,65 @@ def _test_attempt_dispatch_does_not_fire_build_end(_): _eq("attempt fired", recorder["attempt"], [("a", 0)]) _eq("build_end not fired", recorder["build_end"], []) +def _fake_rc(values = {}): + """Stand-in for a `RunCommand`: `target_patterns` calls only `flag_value`. + Same shape as the fakes in `bazel_flags_test.axl` / + `deployment_flags_test.axl`, keyed `{command: {flag: value}}` so a test can + pin that the lookup is command-scoped. The real last-wins / `=`-form / + two-token matching lives in `crates/bazelrc` and is tested there.""" + return struct(flag_value = lambda name, command: values.get(command, {}).get(name)) + +def _test_default_reaches_bazel_when_nothing_else_supplies_patterns(_): + """No pattern file anywhere → the arg default is the target list.""" + _eq( + "default pattern forwarded", + target_patterns(False, ["..."], "", _fake_rc(), "build"), + ["..."], + ) + +def _test_forwarded_flag_suppresses_the_default(_): + """`--bazel-flag=--target_pattern_file=…` with no positionals: Bazel must + see the flag and no residue, or it fails the invocation outright.""" + rc = _fake_rc({"build": {"--target_pattern_file": "/tmp/p"}}) + _eq("default suppressed", target_patterns(False, ["..."], "", rc, "build"), []) + +def _test_explicit_patterns_always_reach_bazel(_): + """Explicit patterns alongside a forwarded pattern file are forwarded as-is + so Bazel reports the conflict — the runner owns no second spelling of it.""" + rc = _fake_rc({"build": {"--target_pattern_file": "/tmp/p"}}) + _eq( + "explicit patterns preserved", + target_patterns(True, ["//foo:bar"], "", rc, "build"), + ["//foo:bar"], + ) + +def _test_aspect_own_flag_suppresses_the_default(_): + """aspect's own `--target-pattern-file` is resolved before the rc exists, so + it is passed in directly rather than read back off `rc`.""" + _eq( + "default suppressed", + target_patterns(False, ["..."], "/tmp/p", _fake_rc(), "build"), + [], + ) + +def _test_lookup_is_command_scoped(_): + """A pattern file set for `build` must not suppress the default for a + `test` run — `flag_value` is asked about the running subcommand.""" + rc = _fake_rc({"build": {"--target_pattern_file": "/tmp/p"}}) + _eq("test unaffected", target_patterns(False, ["..."], "", rc, "test"), ["..."]) + def _test_impl(ctx): _test_attempt_dispatch_empty_is_noop(ctx) _test_build_end_dispatch_empty_is_noop(ctx) _test_attempt_dispatch_invokes_in_order(ctx) _test_build_end_dispatch_invokes_in_order(ctx) _test_attempt_dispatch_does_not_fire_build_end(ctx) - print("bazel_runner_test.axl: OK (5 sections)") + _test_default_reaches_bazel_when_nothing_else_supplies_patterns(ctx) + _test_forwarded_flag_suppresses_the_default(ctx) + _test_explicit_patterns_always_reach_bazel(ctx) + _test_aspect_own_flag_suppresses_the_default(ctx) + _test_lookup_is_command_scoped(ctx) + print("bazel_runner_test.axl: OK (10 sections)") return 0 bazel_runner_tests = task( From 6096f977e5585822d84a76e590724bc33b755d80 Mon Sep 17 00:00:00 2001 From: "Matt (Orion) Cook" Date: Wed, 12 Aug 2026 11:48:36 -0600 Subject: [PATCH 8/9] fix(test): load target_patterns correctly in bazel_runner_test --- .../src/builtins/aspect/private/lib/bazel_runner_test.axl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner_test.axl b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner_test.axl index d942ec60d..059d0bfd7 100644 --- a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner_test.axl +++ b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner_test.axl @@ -15,7 +15,8 @@ Run with: aspect dev test-bazel-runner """ -load("@aspect//bazel.axl", "BazelTrait", "dispatch_bazel_attempt_end", "dispatch_bazel_build_end", "target_patterns") +load("@aspect//bazel.axl", "BazelTrait", "dispatch_bazel_attempt_end", "dispatch_bazel_build_end") +load("./bazel_runner.axl", "target_patterns") def _eq(label, got, want): if got != want: From 483a00be7b254b15f7378ea9297bfc8b9ddeebf0 Mon Sep 17 00:00:00 2001 From: "Matt (Orion) Cook" Date: Wed, 12 Aug 2026 13:09:06 -0600 Subject: [PATCH 9/9] lint: bulidifier run over bazel_runner.axl --- .../aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl index f2c07496f..dbe262055 100644 --- a/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl +++ b/crates/aspect-cli/src/builtins/aspect/private/lib/bazel_runner.axl @@ -218,7 +218,6 @@ def _emit_terminal(ctx, lifecycle, command, data, exit_code, targets = []): flagged = final_status == "warning", ) - def target_patterns(explicit: bool, cli_targets: list[str], own_pattern_file: str, rc, command: str) -> list[str]: """The target patterns to place on Bazel's command line.