Repository: kythe/kythe
Pull request #5923 (2023-11-08 23:59:46)

feat(dev): add aspect-based implementation of extra actions for tests
This allows using cc_library targets directly in verifier tests.


Patch:
diff --git a/tools/build_rules/extra_aspects/BUILD b/tools/build_rules/extra_aspects/BUILD
new file mode 100644
index 000000000..102c2f1c2
--- /dev/null
+++ b/tools/build_rules/extra_aspects/BUILD
@@ -0,0 +1,38 @@
+"""Rules for Aspect-based extra actions."""
+
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load(":extra_actions.bzl", "spawn_info_proto_writer")
+
+package(
+    default_visibility = ["//visibility:public"],
+)
+
+bzl_library(
+    name = "actions",
+    srcs = ["actions.bzl"],
+    deps = [
+        "@bazel_skylib//lib:sets",
+    ],
+)
+
+bzl_library(
+    name = "extra_actions",
+    srcs = ["extra_actions.bzl"],
+    deps = [
+        ":actions",
+        "@bazel_skylib//lib:sets",
+    ],
+)
+
+bzl_library(
+    name = "config",
+    srcs = ["config.bzl"],
+    deps = [
+        ":actions",
+        ":extra_actions",
+        "@bazel_skylib//lib:sets",
+        "@bazel_skylib//lib:structs",
+    ],
+)
+
+spawn_info_proto_writer(name = "spawn-info-writer")
diff --git a/tools/build_rules/extra_aspects/actions.bzl b/tools/build_rules/extra_aspects/actions.bzl
new file mode 100644
index 000000000..576efc6e7
--- /dev/null
+++ b/tools/build_rules/extra_aspects/actions.bzl
@@ -0,0 +1,86 @@
+# Copyright 2023 The Kythe Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Starlark utilities for selecting actions."""
+
+load("@bazel_skylib//lib:sets.bzl", "sets")
+
+def select_target_actions(target, rule, config):
+    """Given a target and a rule, finds actions matching the specified config.
+
+    Args:
+      target: The Target object provided to an aspect implementation.
+      rule: The rule_attributes from an aspect's ctx.rule parameter.
+      config: A KytheActionSelectionInfo instance, configuring action selection.
+
+    Returns:
+      A list of actions selected by the provided configuration.
+    """
+    actions = []
+    if sets.length(config.rules) == 0 or sets.contains(config.rules, rule.kind):
+        actions.extend(_filter_actions(target.actions, config.mnemonics, config.exclude_input_extensions))
+    for attr in config.aspect_rule_attrs.get(rule.kind, []):
+        for dep in getattr(rule.attr, attr, []):
+            # {lang}_proto_library targets are implemented as aspects
+            # with a single "dep" pointing at original proto_library target.
+            # All of the actions come from the aspect as applied to the dep,
+            # which is not a top-level dep, so we need to inspect those as well.
+            # TODO(shahms): It would be nice if there was some way to tell whether or
+            # not an action came from an aspect or the underlying rule. We could then
+            # more generally inspect deps to find actions rather than limiting it to
+            # allowlisted kinds.
+            # This could be accomplished by having `aspect_ids` populated on deps.
+            # We could look specifically for "<merged target" in the string-ified dep,
+            # but that's pretty ugly.
+            # It looks like adding an "aspect_ids" list attribute should be straightforward.
+            # https://github.com/bazelbuild/bazel/blob/master/src/main/java/com/google/devtools/build/lib/analysis/configuredtargets/MergedConfiguredTarget.java#L54
+            # It could also just use ActionOwner information.
+            if str(dep).startswith("<merged target") and hasattr(dep, "actions"):
+                actions.extend(_filter_actions(dep.actions, config.mnemonics, config.exclude_input_extensions))
+    return actions
+
+def config(rules, aspect_rule_attrs, mnemonics, exclude_input_extensions):
+    """Create an action selection config.
+
+    Args:
+     rules: A Skylib set of rule kinds from which to select actions.
+     aspect_rule_attrs: A dict of {kind: [attrs]} of rule kind attributes from which to select actions.
+     mnemonics: A Skylib set of action mnemonics to select.
+     exclude_input_extensions: A Skylib set of file extensions to whose actions to exclude.
+
+    Returns:
+      A struct with corresponding attributes.
+    """
+    return struct(
+        rules = rules,
+        aspect_rule_attrs = aspect_rule_attrs,
+        mnemonics = mnemonics,
+        exclude_input_extensions = exclude_input_extensions,
+    )
+
+def _has_extension(inputs, extensions):
+    if not extensions or sets.length(extensions) == 0:
+        return False
+    for input in inputs.to_list():
+        if sets.contains(extensions, input.extension):
+            return True
+    return False
+
+def _filter_actions(actions, mnemonics, exclude_extensions = None):
+    return [
+        a
+        for a in actions
+        if sets.contains(mnemonics, a.mnemonic) and
+           not _has_extension(a.inputs, exclude_extensions)
+    ]
diff --git a/tools/build_rules/extra_aspects/config.bzl b/tools/build_rules/extra_aspects/config.bzl
new file mode 100644
index 000000000..da12dd4c4
--- /dev/null
+++ b/tools/build_rules/extra_aspects/config.bzl
@@ -0,0 +1,148 @@
+# Copyright 2023 The Kythe Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Kythe extractor configuration rules and providers."""
+
+load("@bazel_skylib//lib:sets.bzl", "sets")
+load("@bazel_skylib//lib:structs.bzl", "structs")
+load(":actions.bzl", _action_selection_config = "config")
+load(
+    ":extra_actions.bzl",
+    "ActionListenerConfigInfo",
+    "ActionListenerInfo",
+    "ProtoWriterInfo",
+    "action_listener_aspect",
+    "extract_target",
+)
+
+KytheExtractorConfigInfo = provider(
+    doc = "Configuration options for Kythe extraction.",
+    fields = {
+        "allowed_rule_kinds": "A Skylib set of allowed rule kinds (or None to allow everything).",
+        "action_listeners": "A depset of ActionListenerInfo from the provided target.",
+        "action_selection_config": "A KytheActionSelectionInfo instance.",
+    },
+)
+
+def _use_default_proto_writer(action_listener, proto_writer):
+    if not getattr(action_listener, "write_extra_action", None):
+        keys = structs.to_dict(action_listener)
+        keys["write_extra_action"] = proto_writer.write_extra_action
+        return ActionListenerInfo(**keys)
+    return action_listener
+
+def _get_transitive_action_listeners(action_listeners, proto_writer):
+    return depset(direct = [
+        _use_default_proto_writer(t[ActionListenerInfo], proto_writer[ProtoWriterInfo])
+        for t in action_listeners
+        if ActionListenerInfo in t
+    ], transitive = [
+        t[ActionListenerConfigInfo].action_listeners
+        for t in action_listeners
+        if ActionListenerConfigInfo in t
+    ])
+
+def _config_impl(ctx):
+    action_listeners = _get_transitive_action_listeners(
+        ctx.attr.action_listeners,
+        ctx.attr.proto_writer,
+    ).to_list()
+
+    # TODO(b/182403136): Workaround for error applying aspects to jsunit_test rules.
+    # They complain about a "non-executable dependency" so we provide a do-nothing executable
+    # file to silence this.
+    dummy_executable = ctx.actions.declare_file(ctx.label.name + ".dummy_exec")
+    ctx.actions.write(
+        output = dummy_executable,
+        content = "",
+        is_executable = True,
+    )
+
+    return [
+        KytheExtractorConfigInfo(
+            action_listeners = action_listeners,
+            allowed_rule_kinds = _allowed_rule_kinds(ctx.attr.rules, ctx.attr.aspect_rule_attrs),
+            action_selection_config = _action_selection_config(
+                rules = sets.make(ctx.attr.rules),
+                aspect_rule_attrs = ctx.attr.aspect_rule_attrs,
+                mnemonics = sets.union(*[a.mnemonics for a in action_listeners]),
+                exclude_input_extensions = sets.make(ctx.attr.exclude_input_extensions),
+            ),
+        ),
+        DefaultInfo(
+            executable = dummy_executable,
+        ),
+    ]
+
+kythe_extractor_config = rule(
+    implementation = _config_impl,
+    attrs = {
+        "rules": attr.string_list(mandatory = True),
+        "aspect_rule_attrs": attr.string_list_dict(),
+        "action_listeners": attr.label_list(
+            aspects = [action_listener_aspect],
+            providers = [
+                [ActionListenerInfo],
+                [ActionListenerConfigInfo],
+            ],
+            allow_rules = ["action_listener"],
+        ),
+        "proto_writer": attr.label(
+            default = Label("//tools/build_rules/extra_aspects:spawn-info-writer"),
+            providers = [ProtoWriterInfo],
+        ),
+        "exclude_input_extensions": attr.string_list(),
+    },
+    provides = [KytheExtractorConfigInfo],
+)
+
+def _action_listener_config_impl(ctx):
+    return [
+        ActionListenerConfigInfo(
+            action_listeners = _get_transitive_action_listeners(ctx.attr.action_listeners, ctx.attr.proto_writer),
+        ),
+    ]
+
+action_listener_config = rule(
+    implementation = _action_listener_config_impl,
+    attrs = {
+        "action_listeners": attr.label_list(
+            aspects = [action_listener_aspect],
+            providers = [
+                [ActionListenerInfo],
+                [ActionListenerConfigInfo],
+            ],
+            allow_rules = ["action_listener"],
+        ),
+        "proto_writer": attr.label(
+            mandatory = True,
+            providers = [ProtoWriterInfo],
+        ),
+    },
+    provides = [ActionListenerConfigInfo],
+)
+
+def _allowed_rule_kinds(rules, aspect_rule_attrs):
+    if not rules:
+        return None  # An empty `rules` attribute means allow all kinds.
+    return sets.union(sets.make(rules), sets.make(aspect_rule_attrs.keys()))
+
+def run_configured_extractor(target, ctx, config):
+    # If there is an explicit allowlist of rule kinds, only extract those.
+    # Attempting to extract an unknown rule kind is not an error.
+    # As command-line aspects are called for all top-level targets,
+    # they must be able to quietly handle this case.
+    if config.allowed_rule_kinds and not sets.contains(config.allowed_rule_kinds, ctx.rule.kind):
+        return depset()
+    return extract_target(target, ctx, config)
diff --git a/tools/build_rules/extra_aspects/cxx/BUILD b/tools/build_rules/extra_aspects/cxx/BUILD
new file mode 100644
index 000000000..80a51b86d
--- /dev/null
+++ b/tools/build_rules/extra_aspects/cxx/BUILD
@@ -0,0 +1,46 @@
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load("//tools/build_rules/extra_aspects:config.bzl", "kythe_extractor_config")
+load(":config.bzl", "cpp_compile_info_proto_writer")
+
+kythe_extractor_config(
+    name = "extractor-config",
+    action_listeners = [
+        "//kythe/extractors:extract_kzip_cxx",
+    ],
+    aspect_rule_attrs = {"cc_proto_library": []},
+    proto_writer = ":cpp-compile-info-writer",
+    rules = [
+        "cc_binary",
+        "cc_library",
+        "cc_test",
+        "cc_stubby_library",
+        "py_extension",
+        "proto_library",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+cpp_compile_info_proto_writer(name = "cpp-compile-info-writer")
+
+bzl_library(
+    name = "config",
+    srcs = ["config.bzl"],
+    deps = ["//tools/build_rules/extra_aspects:extra_actions"],
+)
+
+bzl_library(
+    name = "aspect",
+    srcs = ["aspect.bzl"],
+    deps = [
+        "//tools/build_rules/extra_aspects:config",
+    ],
+)
+
+bzl_library(
+    name = "rules",
+    srcs = ["rules.bzl"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":aspect",
+    ],
+)
diff --git a/tools/build_rules/extra_aspects/cxx/aspect.bzl b/tools/build_rules/extra_aspects/cxx/aspect.bzl
new file mode 100644
index 000000000..0e480cb6d
--- /dev/null
+++ b/tools/build_rules/extra_aspects/cxx/aspect.bzl
@@ -0,0 +1,79 @@
+# Copyright 2023 The Kythe Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""C++ extraction aspect definition."""
+
+load(
+    "//tools/build_rules/extra_aspects:config.bzl",
+    "KytheExtractorConfigInfo",
+    "run_configured_extractor",
+)
+
+KytheCxxCompilationInfo = provider("C++ KytheCompilationInfo", fields = ["kzips"])
+
+_KytheCxxProtoCompilationInfo = provider("", fields = ["kzips"])
+
+_COMMON_ATTRS = {
+    "_config": attr.label(
+        providers = [KytheExtractorConfigInfo],
+        default = Label("//tools/build_rules/extra_aspects/cxx:extractor-config"),
+    ),
+}
+
+def _transitive_kzips(ctx, provider = _KytheCxxProtoCompilationInfo):
+    return [
+        dep[provider].kzips
+        for dep in ctx.rule.attr.deps
+        if provider in dep and dep[provider].kzips
+    ]
+
+def _extract_target(target, ctx, transitive):
+    config = ctx.attr._config[KytheExtractorConfigInfo]
+    kzips = [run_configured_extractor(target, ctx, config)]
+    if transitive:
+        kzips += _transitive_kzips(ctx)
+    return depset(transitive = kzips)
+
+def _extract_proto_impl(target, ctx):
+    return [_KytheCxxProtoCompilationInfo(kzips = _extract_target(
+        target,
+        ctx,
+        transitive = True,
+    ))]
+
+_extract_proto_aspect = aspect(
+    implementation = _extract_proto_impl,
+    attr_aspects = ["deps"],
+    # Only call the aspect on proto_library-like targets.
+    required_providers = [ProtoInfo],
+    # We need the actions provided by the aspect, rather than the provider itself,
+    # but CcProtoInfo aspect isn't exported from the rules.
+    required_aspect_providers = [[CcInfo]],
+    attrs = _COMMON_ATTRS,
+)
+
+def _extract_impl(target, ctx):
+    config = ctx.attr._config[KytheExtractorConfigInfo].action_selection_config
+    return [KytheCxxCompilationInfo(kzips = _extract_target(
+        target,
+        ctx,
+        transitive = [] == config.aspect_rule_attrs.get(ctx.rule.kind),
+    ))]
+
+extract_cxx_aspect = aspect(
+    implementation = _extract_impl,
+    requires = [_extract_proto_aspect],
+    attrs = _COMMON_ATTRS,
+    provides = [KytheCxxCompilationInfo],
+)
diff --git a/tools/build_rules/extra_aspects/cxx/config.bzl b/tools/build_rules/extra_aspects/cxx/config.bzl
new file mode 100644
index 000000000..43b3161c6
--- /dev/null
+++ b/tools/build_rules/extra_aspects/cxx/config.bzl
@@ -0,0 +1,108 @@
+# Copyright 2023 The Kythe Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""C++ Extractor configuration."""
+
+load(
+    "//tools/build_rules/extra_aspects:extra_actions.bzl",
+    "ProtoWriterInfo",
+    "normalize_action",
+)
+
+def _has_assembly_source(action, inputs):
+    for file in inputs:
+        if action.argv and file.path not in action.argv:
+            # If we have a usable argv, check that the input is present
+            # to ignore non-source input files.
+            # Prior to Starlarkification, argv was not useful.
+            # When extracting via the test transition, `inputs` contains
+            # some toolchain-specific assembly files that aren't actually
+            # used by the compilation, which will trigger false-positives below.
+            continue
+        if file.extension in ("s", "S"):
+            return True
+    return False
+
+def _as_cpp_compile_info(target, action):
+    action = normalize_action(action)
+    inputs = action.inputs.to_list()
+
+    # Entirely skip compilations containing assembly.
+    if _has_assembly_source(action, inputs):
+        return None
+
+    return struct(**{
+        "owner": str(target.label),
+        "mnemonic": action.mnemonic,
+        "[blaze.CppCompileInfo.cpp_compile_info]": struct(
+            variable = [struct(name = k, value = v) for k, v in action.env.items()],
+            sources_and_headers = [f.path for f in inputs],
+            output_file = [f.path for f in action.outputs.to_list()][0],
+        ),
+    })
+
+def _write_cpp_compile_info(executable, target, ctx, action_id, action):
+    cpp_info = _as_cpp_compile_info(target, action)
+    if not cpp_info:
+        return None
+    txtpb = ctx.actions.declare_file(action_id + ".xa.textproto")
+    ctx.actions.write(
+        output = txtpb,
+        content = proto.encode_text(cpp_info),
+    )
+    output = ctx.actions.declare_file(action_id + ".xa")
+    args = ctx.actions.args()
+    args.add(txtpb)
+    args.add(output)
+    args.add("--")
+
+    ctx.actions.run(
+        outputs = [output],
+        inputs = depset([txtpb]),
+        arguments = [args] + action.args,
+        executable = executable,
+        mnemonic = "KytheWriteCppCompileInfo",
+        # In theory, the exact environment and inputs can change during execution.
+        # In order to record this, we shadow the original action when writing the proto.
+        shadowed_action = action,
+        toolchain = None,
+    )
+    return output
+
+def _cpp_compile_info_impl(ctx):
+    executable = ctx.executable._write_extra_action
+
+    def write_extra_action(target, ctx, action_id, action):
+        return (
+            _write_cpp_compile_info(executable, target, ctx, action_id, action),
+            depset(),
+        )
+
+    return [
+        ProtoWriterInfo(
+            write_extra_action = write_extra_action,
+        ),
+    ]
+
+cpp_compile_info_proto_writer = rule(
+    implementation = _cpp_compile_info_impl,
+    attrs = {
+        "_write_extra_action": attr.label(
+            default = Label("//kythe/go/util/tools/write_extra_action"),
+            executable = True,
+            cfg = "exec",
+        ),
+    },
+    provides = [ProtoWriterInfo],
+)
diff --git a/tools/build_rules/extra_aspects/cxx/rules.bzl b/tools/build_rules/extra_aspects/cxx/rules.bzl
new file mode 100644
index 000000000..fbdd07b7b
--- /dev/null
+++ b/tools/build_rules/extra_aspects/cxx/rules.bzl
@@ -0,0 +1,59 @@
+# Copyright 2023 The Kythe Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""C++ extraction rules."""
+
+load(":aspect.bzl", "KytheCxxCompilationInfo", "extract_cxx_aspect")
+
+_EXTRACTION_SETTINGS = {
+    # We don't want and can't use module maps for extraction/indexing.
+    "//command_line_option:features": ["-use_module_maps", "-module_maps"],
+}
+
+def _cc_extract_kzip_impl(ctx):
+    return [
+        DefaultInfo(files = depset(transitive = [
+            d[KytheCxxCompilationInfo].kzips
+            for d in ctx.attr.deps
+            if KytheCxxCompilationInfo in d and d[KytheCxxCompilationInfo].kzips
+        ])),
+    ]
+
+def _transition_impl(settings, attr):
+    if not attr.disable_modules:
+        return None
+    return _EXTRACTION_SETTINGS
+
+_transition = transition(
+    implementation = _transition_impl,
+    inputs = [],
+    outputs = _EXTRACTION_SETTINGS.keys(),
+)
+
+cc_extract_kzip = rule(
+    implementation = _cc_extract_kzip_impl,
+    attrs = {
+        "deps": attr.label_list(
+            mandatory = True,
+            aspects = [extract_cxx_aspect],
+            cfg = _transition,
+        ),
+        "disable_modules": attr.bool(
+            default = True,
+        ),
+        "_allowlist_function_transition": attr.label(
+            default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
+        ),
+    },
+)
diff --git a/tools/build_rules/extra_aspects/extra_actions.bzl b/tools/build_rules/extra_aspects/extra_actions.bzl
new file mode 100644
index 000000000..ac8121729
--- /dev/null
+++ b/tools/build_rules/extra_aspects/extra_actions.bzl
@@ -0,0 +1,430 @@
+# Copyright 2023 The Kythe Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Aspect-based re-implementation of extra_actions."""
+
+load("@bazel_skylib//lib:sets.bzl", "sets")
+load(":actions.bzl", "select_target_actions")
+
+ExtraActionInfo = provider(
+    doc = "Relevant information from extra_action rules.",
+    fields = {
+        "label": "The label of the captured extra action.",
+        "cmd": "The action command template.",
+        "out_templates": "A list of output templates.",
+        "requires_action_output": "Boolean indicating whether or not the extra action requires the primary action output.",
+        "tools": "A list of tools required by the action.",
+        "inputs": "The list required inputs.",
+        "input_manifests": "List of input_manifests as returned by resolve_command",
+    },
+)
+
+ActionListenerInfo = provider(
+    doc = "Collected information from extra_action and action_listener rules.",
+    fields = {
+        "mnemonics": "A Skylib set of action mnemonics on which to trigger.",
+        "extra_actions": "A list of extra actions to trigger.",
+        "write_extra_action": "A callable which will be used to write the extra action protobuf.",
+    },
+)
+
+ActionListenerConfigInfo = provider(
+    doc = "An aggregated ActionListenerInfo.",
+    fields = {
+        "action_listeners": "A depset of ActionListenerInfo instances.",
+    },
+)
+
+ProtoWriterInfo = provider(
+    doc = "Information and tools required for turning an action into a protobuf.",
+    fields = {
+        "write_extra_action": "A callable which will be used to write the extra action protobuf.",
+    },
+)
+
+def _elems(value):
+    """Either value.elems() or the value itself."""
+    return getattr(value, "elems", lambda: value)()
+
+_SAFE_CHARS = {
+    ch: ch
+    for ch in _elems("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+")
+}
+
+# Output IDs longer than this will be hashed.
+# As this currently uses Starlark's built-in hash(),
+# which is specified to be Java's String.hashValue(),
+# which is, in turn, a terrible hash function, we only do
+# this as a last resort.
+_MAX_ID_PATH_LENGTH = 200
+
+def _shell_safe(value):
+    """Replaces any non-safe char with +."""
+    return "".join([_SAFE_CHARS.get(ch, "+") for ch in _elems(value)])
+
+def _remove_prefix(haystack, needle):
+    if haystack.startswith(needle):
+        return True, haystack[len(needle):]
+    return False, haystack
+
+def _direct_label(target):
+    # A dirty, dirty hack to deal with alias() and bind() targets.
+    # target.label resolves the alias, but resolve_command needs the original label.
+    found, target_str = _remove_prefix(str(target), "<alias target ")
+    if found:
+        return Label(target_str[:target_str.find(" ")])
+    return target.label
+
+def _extra_aspect_impl(target, ctx):
+    for provider in [ActionListenerInfo, ActionListenerConfigInfo, ExtraActionInfo]:
+        if provider in target:
+            return []
+
+    if ctx.rule.kind == "action_listener":
+        return [
+            ActionListenerInfo(
+                mnemonics = sets.make(ctx.rule.attr.mnemonics),
+                extra_actions = [
+                    dep[ExtraActionInfo]
+                    for dep in ctx.rule.attr.extra_actions
+                    if ExtraActionInfo in dep
+                ],
+            ),
+        ]
+    if ctx.rule.kind == "extra_action":
+        # cmd needs to be expanded in the context of the extra_action itself
+        # so that relative $(location :target) paths resolve correctly.  We
+        # need to use ctx.resolve_command() to do so as ctx.expand_location()
+        # complains that java_binary targets export multiple files.
+        # https://github.com/bazelbuild/bazel/issues/10067
+        inputs, command, input_manifests = ctx.resolve_command(
+            command = ctx.rule.attr.cmd,
+            attribute = "cmd",
+            expand_locations = True,
+            tools = ctx.rule.attr.tools,
+            label_dict = {
+                _direct_label(d): d.files.to_list()
+                for d in ctx.rule.attr.data
+            },
+        )
+
+        return [
+            ExtraActionInfo(
+                label = target.label,
+                cmd = command[-1],  # resolve_command returns a list invoking bash, we just want the script.
+                out_templates = ctx.rule.attr.out_templates,
+                requires_action_output = ctx.rule.attr.requires_action_output,
+                tools = ctx.rule.files.tools,
+                inputs = inputs + ctx.rule.files.data,
+                input_manifests = input_manifests,
+            ),
+        ]
+    return []
+
+def _is_middleman_file(file):
+    # This is a hack to exclude irrelevant and non-existent input files which
+    # are otherwise present for some kinds of binary rules.
+    return file.short_path.startswith("_middlemen/")
+
+def normalize_action(action):
+    """Returns a struct with the same attributes as the action, but with normalized values.
+
+    Sorts action.env, sorts and filters out "middleman" artifacts from inputs and outputs.
+
+    Args:
+     action: The action to inspect and transform.
+
+    Returns:
+     A struct with the same attributes as the input action and a
+     normalized attribute indicating the transformation.
+    """
+    if getattr(action, "normalized", False):
+        return action
+    values = {}
+    for key in dir(action):
+        if key in ("to_proto", "to_json"):
+            continue
+        elif key in ("inputs", "outputs"):
+            # convert back to a depset to preserve API.
+            value = depset(sorted([
+                file
+                for file in getattr(action, key).to_list()
+                if not _is_middleman_file(file)
+            ]))
+        elif key == "env":
+            # dicts maintain insertion order.
+            value = {k: v for k, v in sorted(getattr(action, key).items())}
+        else:
+            value = getattr(action, key)
+        values[key] = value
+    return struct(normalized = True, original_action = action, **values)
+
+def _original_action(action):
+    if type(action) == "Action":
+        return action
+    return getattr(action, "original_action", None)
+
+def as_spawn_info_struct(target, action):
+    """Returns a Starlark struct which, when converted to proto, will be a valid SpawnInfo extra action."""
+    action = normalize_action(action)
+    return struct(**{
+        "owner": str(target.label),
+        "mnemonic": action.mnemonic,
+        "[blaze.SpawnInfo.spawn_info]": struct(
+            argument = action.argv,
+            variable = [struct(name = k, value = v) for k, v in action.env.items()],
+            input_file = [f.path for f in action.inputs.to_list()],
+            output_file = [f.path for f in action.outputs.to_list()],
+        ),
+    })
+
+action_listener_aspect = aspect(
+    implementation = _extra_aspect_impl,
+    attr_aspects = ["extra_actions"],
+)
+
+def write_extra_action_struct(ctx, executable, action_id, info_struct, mnemonic):
+    """Writes given ExtraActionInfo struct to a file in binary form.
+
+    Additionally creates intermediate .xa.textproto file with the proto in textproto form.
+    The latter file is useful for local debugging.
+
+    Args:
+        ctx: The Bazel-provided aspect rule context.
+        executable: Label to //kythe/go/util/tools/write_extra_action executable.
+        action_id: Action id used to generate unique file names.
+        info_struct: struct representing ExtraActionInfo proto.
+        mnemonic: Mnemonic to use for the action that writes ExtraActionInfo proto.
+
+    Returns:
+      A file containing binary ExtraActionInfo proto.
+    """
+    txtpb = ctx.actions.declare_file(action_id + ".xa.textproto")
+    ctx.actions.write(
+        output = txtpb,
+        content = proto.encode_text(info_struct),
+    )
+    output = ctx.actions.declare_file(action_id + ".xa")
+    args = ctx.actions.args()
+    args.add(txtpb)
+    args.add(output)
+    ctx.actions.run(
+        outputs = [output],
+        inputs = [txtpb],
+        arguments = [args],
+        executable = executable,
+        mnemonic = mnemonic,
+        toolchain = None,
+    )
+    return output
+
+def _common_prefix_length(a, b):
+    for i in range(min(len(a), len(b))):
+        if a[i] != b[i]:
+            return i
+    return min(len(a), len(b))
+
+def _output_id(label, action, default):
+    """Returns a identifier based on the action outputs."""
+    prefixes = [
+        (_common_prefix_length(label.package, output.short_path), output.short_path)
+        for output in action.outputs.to_list()
+    ]
+    if not prefixes:
+        return default
+
+    # Use the output with the longest common shared prefix with the package.
+    n, path = max(prefixes)
+
+    # Replace uses of the label in the output name with its hash as we
+    # already use the label in the output name elsewhere and sometimes it's really long.
+    # Additionally replace semantic characters with safer alternatives.
+    id = path[n:].strip("/").replace(label.name, str(hash(label.name))).replace(".", "+").replace("/", "+")
+    if len(id) + len(label.package) + len(label.name) > _MAX_ID_PATH_LENGTH:
+        hashed = str(hash(id))
+        truncate = _MAX_ID_PATH_LENGTH - (len(hashed) + len(label.package) + len(label.name))
+        if 0 < truncate and truncate < len(id):
+            return id[:truncate] + hashed
+        return hashed
+    return id
+
+def _action_id_map(target, actions):
+    """Returns a dict mapping from an action id string to the action."""
+    return {
+        # b/178768424: Prior to cl/354550518, salvation_worker splats everything after the
+        # *first* dot into the "extension" field, which causes Monarch quota issues.
+        # Replace any errant dots with "+" to avoid this behavior in the meantime.
+        _shell_safe("{}-{}-{}".format(
+            target.label.name.replace(".", "+"),
+            action.mnemonic,
+            _output_id(target.label, action, default = str(i)),
+        )): action
+        for i, action in _action_items(actions)
+    }
+
+def _action_items(actions):
+    """Returns either actions.items() or enumerate(actions)."""
+    return getattr(actions, "items", lambda: enumerate(actions))()
+
+def _run_extra_action(target, ctx, write_extra_action, xainfo, action_id, action):
+    if type(xainfo) == "Target":
+        xainfo = xainfo[ExtraActionInfo]
+
+    xa_file, extra_inputs = write_extra_action(
+        target,
+        ctx,
+        action_id,
+        action,
+    )
+
+    if not xa_file:
+        return []
+
+    output_map = {
+        template: ctx.actions.declare_file(template.replace("$(ACTION_ID)", action_id))
+        for template in xainfo.out_templates
+    }
+
+    inputs = [action.inputs]
+    if xainfo.requires_action_output:
+        inputs.append(action.outputs)
+    if xainfo.inputs != None:
+        inputs.append(depset(xainfo.inputs))
+
+    inputs.append(extra_inputs)
+
+    cmd = xainfo.cmd.replace("$(EXTRA_ACTION_FILE)", xa_file.path)
+    for template, output in output_map.items():
+        cmd = cmd.replace("$(output {})".format(template), output.path)
+
+    kwargs = {}
+    shadowed_action = _original_action(action)
+    if shadowed_action != None:
+        kwargs["shadowed_action"] = shadowed_action
+
+    ctx.actions.run_shell(
+        inputs = depset(direct = [xa_file], transitive = inputs),
+        outputs = output_map.values(),
+        mnemonic = "KytheExtraAction" + action.mnemonic,
+        progress_message = "Executing extra_action {} on {}".format(xainfo.label, target.label),
+        tools = xainfo.tools,
+        command = cmd,
+        input_manifests = xainfo.input_manifests,
+        toolchain = None,
+        **kwargs
+    )
+    return output_map.values()
+
+def _run_extra_actions(target, ctx, write_extra_action, extra_actions, actions):
+    """Run the list of ExtraActionInfo over the provided actions.
+
+    Args:
+      target: The Bazel Target on which to run extra actions.
+      ctx: The rule context provided to the aspect.
+      write_extra_action: A callable used to write the .xa proto file.
+      extra_actions: The list of ExtraActionInfo providers to use.
+      actions: The list of rule actions for which to run the extra actions.
+
+    Returns:
+      A list of output File objects produced by the extra actions.
+    """
+    outputs = []
+    for xainfo in extra_actions:
+        for id, action in actions.items():
+            outputs.extend(_run_extra_action(target, ctx, write_extra_action, xainfo, id, action))
+    return outputs
+
+def run_action_listener(target, ctx, listener, actions):
+    """Run the action listener over matching actions.
+
+    Args:
+      target: The Bazel Target on which to run extra actions.
+      ctx: The rule context provided to the aspect.
+      listener: The ActionListenerInfo providers to use.
+      actions: The list or dict of rule actions for which to run the extra actions.
+
+    Returns:
+      A list of output File objects produced by the extra actions.
+    """
+    if type(listener) == "Target":
+        listener = listener[ActionListenerInfo]
+
+    actions = _action_id_map(target, actions)
+    return _run_extra_actions(
+        target,
+        ctx,
+        listener.write_extra_action,
+        listener.extra_actions,
+        {
+            id: action
+            for id, action in actions.items()
+            if sets.contains(listener.mnemonics, action.mnemonic)
+        },
+    )
+
+def extract_target(target, ctx, config):
+    """Extracts the provided target with the given config.
+
+    Args:
+      target: The Bazel-provided Target object to extract.
+      ctx: The Bazel-provided aspect rule context.
+      config: The KytheExtractorConfigInfo to use for extraction.
+
+    Returns:
+      A depset of kzip files.
+    """
+    actions = select_target_actions(
+        target,
+        ctx.rule,
+        config.action_selection_config,
+    )
+    kzips = []
+    for listener in config.action_listeners:
+        kzips.extend(run_action_listener(
+            target,
+            ctx,
+            listener,
+            actions,
+        ))
+    return depset(kzips)
+
+def _spawn_info_impl(ctx):
+    executable = ctx.executable._write_extra_action
+
+    def _write_spawn_info_proto(target, ctx, action_id, action):
+        return write_extra_action_struct(
+            ctx = ctx,
+            executable = executable,
+            action_id = action_id,
+            info_struct = as_spawn_info_struct(target, action),
+            mnemonic = "KytheWriteSpawnInfo",
+        ), depset()
+
+    return [
+        ProtoWriterInfo(
+            write_extra_action = _write_spawn_info_proto,
+        ),
+    ]
+
+spawn_info_proto_writer = rule(
+    implementation = _spawn_info_impl,
+    attrs = {
+        "_write_extra_action": attr.label(
+            default = Label("//kythe/go/util/tools/write_extra_action"),
+            executable = True,
+            cfg = "exec",
+        ),
+    },
+    provides = [ProtoWriterInfo],
+)


Test patch:
diff --git a/kythe/cxx/indexer/cxx/testdata/BUILD b/kythe/cxx/indexer/cxx/testdata/BUILD
index accf571fe..55d35aee6 100644
--- a/kythe/cxx/indexer/cxx/testdata/BUILD
+++ b/kythe/cxx/indexer/cxx/testdata/BUILD
@@ -1,8 +1,8 @@
 load(
     "//tools/build_rules/verifier_test:cc_indexer_test.bzl",
-    "cc_extract_kzip",
     "cc_extractor_test",
     "cc_indexer_test",
+    "cc_write_kzip",
     "objc_indexer_test",
 )
 load("//kythe/go/test/tools/empty_corpus_checker:empty_corpus_test.bzl", "empty_corpus_test")
@@ -707,7 +707,7 @@ cc_indexer_test(
     tags = ["basic"],
 )
 
-cc_extract_kzip(
+cc_write_kzip(
     name = "usr_macro_default_kzip",
     srcs = ["basic/usr_macro_default.cc"],
     corpus = "unit",
@@ -746,7 +746,7 @@ cc_indexer_test(
     tags = ["basic"],
 )
 
-cc_extract_kzip(
+cc_write_kzip(
     name = "usr_var_default_kzip",
     srcs = ["basic/usr_var_default.cc"],
     corpus = "unit",
@@ -922,7 +922,7 @@ cc_indexer_test(
     ],
 )
 
-cc_extract_kzip(
+cc_write_kzip(
     name = "vname_corpus_kzip",
     srcs = ["basic/vname_corpus.cc"],
     corpus = "unit",
diff --git a/kythe/cxx/indexer/cxx/testdata/proto/cc_proto_verifier_test.bzl b/kythe/cxx/indexer/cxx/testdata/proto/cc_proto_verifier_test.bzl
index 76e4ac393..57c7e2bf5 100644
--- a/kythe/cxx/indexer/cxx/testdata/proto/cc_proto_verifier_test.bzl
+++ b/kythe/cxx/indexer/cxx/testdata/proto/cc_proto_verifier_test.bzl
@@ -13,7 +13,7 @@
 # limitations under the License.
 """C++ protocol buffer metadata test rules."""
 
-load("//tools/build_rules/verifier_test:cc_indexer_test.bzl", "cc_extract_kzip", "cc_index", "cc_kythe_proto_library")
+load("//tools/build_rules/verifier_test:cc_indexer_test.bzl", "cc_index", "cc_kythe_proto_library", "cc_write_kzip")
 load("//tools/build_rules/verifier_test:verifier_test.bzl", "index_compilation", "verifier_test")
 load(
     "//kythe/cxx/indexer/proto/testdata:proto_verifier_test.bzl",
@@ -74,7 +74,7 @@ def cc_proto_verifier_test(
     ]
 
     cc_kzip = _invoke(
-        cc_extract_kzip,
+        cc_write_kzip,
         name = name + "_cc_kzip",
         srcs = srcs,
         deps = cc_proto_libs + cc_deps,
@@ -106,7 +106,7 @@ def cc_proto_verifier_test(
             "--claim_unknown=false",
         ]
         claim_deps = [claim_file]
-    
+
     aw_opt = []
     if experimental_set_aliases_as_writes:
         aw_opt = ["--experimental_set_aliases_as_writes"]
diff --git a/tools/build_rules/verifier_test/cc_indexer_test.bzl b/tools/build_rules/verifier_test/cc_indexer_test.bzl
index 6576ffe50..5f3f90423 100644
--- a/tools/build_rules/verifier_test/cc_indexer_test.bzl
+++ b/tools/build_rules/verifier_test/cc_indexer_test.bzl
@@ -301,7 +301,7 @@ cc_kythe_proto_library = rule(
     implementation = _cc_kythe_proto_library,
 )
 
-def _cc_extract_kzip_impl(ctx):
+def _cc_write_kzip_impl(ctx):
     extractor_toolchain = find_extractor_toolchain(ctx)
     cpp = extractor_toolchain.cc_toolchain
     cc_info = cc_common.merge_cc_infos(cc_infos = [
@@ -348,7 +348,7 @@ def _cc_extract_kzip_impl(ctx):
         KytheVerifierSources(files = depset(ctx.files.srcs)),
     ]
 
-cc_extract_kzip = rule(
+cc_write_kzip = rule(
     attrs = {
         "srcs": attr.label_list(
             doc = "A list of C++ source files to extract.",
@@ -397,14 +397,14 @@ cc_extract_kzip = rule(
             providers = [CxxExtractorToolchainInfo],
         ),
     },
-    doc = """cc_extract_kzip extracts srcs into CompilationUnits.
+    doc = """cc_write_kzip extracts srcs into CompilationUnits.
 
     Each file in srcs will be extracted into a separate .kzip file, based on the name
     of the source.
     """,
     fragments = ["cpp"],
     toolchains = CXX_EXTRACTOR_TOOLCHAINS,
-    implementation = _cc_extract_kzip_impl,
+    implementation = _cc_write_kzip_impl,
 )
 
 def _extract_bundle_impl(ctx):
@@ -981,7 +981,7 @@ def cc_extractor_test(
         target_compatible_with = []):
     """C++ verifier test on an extracted source file."""
     args = ["-std=" + std, "-c"]
-    cc_extract_kzip(
+    cc_write_kzip(
         name = name + "_kzip",
         testonly = True,
         srcs = srcs,