Make transitionDirectoryNameFragment a hash of the following
(1) names and current values of all native options that have been touched by starlark transitions so far in the build*
(2) names and current values of all starlark options in map that have different values from the command line configuration.
(3) names and current values of all options affected by current transition (already encompassed by (1) and (2), but explicitly noting for clarity)

This ensures that the hexDigest is only keeping track of new states that are actually different from the previous state i.e. the most recent transition actual creates a different configuration from the last transition.

Previous logic hashed all changes in current transition and appended has onto previous hash -> broke directly building outputs of targets with starlark rule class transitions.

*In the case that we have the sequence StarlarkTransitionFoo set optionA="blah" -> NativeTransitionFoo set optionA="woof", we still include optionA="woof" in the hash. This value is potentially already reflected elsewhere in the buildmnemonic[1] because it could be used to compute its fragment's output directory string, but we're willing to accept this potential overlap for sake of simplicity+correctness. Fragments compute their output directory strings however they want, so we have no guarantee that any given option is included in its fragment's output directory computation (and according to gregce@, generally low fraction of options per fragment are used).

It's important to note that the strategy in this CL isn't optimal. If you look at StarlarkAttrTransitionProviderTest, you can see examples of situation where two identical configurations end up with different hashes because of this hashing strategy. This mostly stems from build setting defaults not being stored in the starlark options map, not knowing all top level configuration values, not wanting to have a hash for outputs in the command line configuration, etc. But this should be *correct* in the sense that two configured targets with genuinely different configurations will not have the same hash (which could lead to action conflicts).

Along the way, also only trigger pre- and post-work relevent to starlark transitions IFF there's a starlark transition being applied.

TESTED=devblaze build experimental/users/kaipi/blaze:rule_class_transition.output
PiperOrigin-RevId: 259031426
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/config/ConfigurationResolver.java b/src/main/java/com/google/devtools/build/lib/analysis/config/ConfigurationResolver.java
index bd2876e..fd6e131 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/config/ConfigurationResolver.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/config/ConfigurationResolver.java
@@ -534,16 +534,22 @@
       Map<PackageValue.Key, PackageValue> buildSettingPackages,
       ExtendedEventHandler eventHandler)
       throws TransitionException {
-    BuildOptions fromOptionsWithDefaults =
-        addDefaultStarlarkOptions(
-            fromOptions,
-            StarlarkTransition.getDefaultInputValues(buildSettingPackages, transition));
+    boolean doesStarlarkTransition = StarlarkTransition.doesStarlarkTransition(transition);
+    if (doesStarlarkTransition) {
+      fromOptions =
+          addDefaultStarlarkOptions(
+              fromOptions,
+              StarlarkTransition.getDefaultInputValues(buildSettingPackages, transition));
+    }
 
     // TODO(bazel-team): safety-check that this never mutates fromOptions.
-    List<BuildOptions> result = transition.apply(fromOptionsWithDefaults);
+    List<BuildOptions> result = transition.apply(fromOptions);
 
-    StarlarkTransition.replayEvents(eventHandler, transition);
-    return StarlarkTransition.validate(transition, buildSettingPackages, result);
+    if (doesStarlarkTransition) {
+      StarlarkTransition.replayEvents(eventHandler, transition);
+      result = StarlarkTransition.validate(transition, buildSettingPackages, result);
+    }
+    return result;
   }
 
   private static BuildOptions addDefaultStarlarkOptions(
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/config/CoreOptions.java b/src/main/java/com/google/devtools/build/lib/analysis/config/CoreOptions.java
index 9497e68..b236f92 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/config/CoreOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/config/CoreOptions.java
@@ -21,6 +21,7 @@
 import com.google.devtools.build.lib.analysis.config.CoreOptionConverters.LabelListConverter;
 import com.google.devtools.build.lib.cmdline.Label;
 import com.google.devtools.build.lib.util.RegexFilter;
+import com.google.devtools.common.options.Converter;
 import com.google.devtools.common.options.Converters;
 import com.google.devtools.common.options.EnumConverter;
 import com.google.devtools.common.options.Option;
@@ -29,6 +30,7 @@
 import com.google.devtools.common.options.OptionEffectTag;
 import com.google.devtools.common.options.OptionMetadataTag;
 import com.google.devtools.common.options.OptionsParser;
+import com.google.devtools.common.options.OptionsParsingException;
 import com.google.devtools.common.options.TriState;
 import java.util.AbstractMap.SimpleEntry;
 import java.util.LinkedHashMap;
@@ -251,7 +253,7 @@
   public String outputDirectoryName;
 
   /**
-   * This option is used by skylark transitions to add a disginguishing element to the output
+   * This option is used by starlark transitions to add a distinguishing element to the output
    * directory name, in order to avoid name clashing.
    */
   @Option(
@@ -266,6 +268,38 @@
       metadataTags = {OptionMetadataTag.INTERNAL})
   public String transitionDirectoryNameFragment;
 
+  /** Regardless of input, converts to an empty list. For use with affectedByStarlarkTransition */
+  public static class EmptyListConverter implements Converter<List<String>> {
+    @Override
+    public List<String> convert(String input) throws OptionsParsingException {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public String getTypeDescription() {
+      return "Regardless of input, converts to an empty list. For use with"
+          + " affectedByStarlarkTransition";
+    }
+  }
+
+  /**
+   * This internal option is a *set* of names (e.g. "cpu") of *native* options that have been
+   * changed by starlark transitions at any point in the build at the time of accessing. This is
+   * used to regenerate {@code transitionDirectoryNameFragment} after each starlark transition.
+   */
+  @Option(
+      name = "affected by starlark transition",
+      defaultValue = "",
+      converter = EmptyListConverter.class,
+      documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+      effectTags = {
+        OptionEffectTag.LOSES_INCREMENTAL_STATE,
+        OptionEffectTag.AFFECTS_OUTPUTS,
+        OptionEffectTag.LOADING_AND_ANALYSIS
+      },
+      metadataTags = {OptionMetadataTag.INTERNAL})
+  public List<String> affectedByStarlarkTransition;
+
   @Option(
       name = "platform_suffix",
       defaultValue = "null",
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/skylark/FunctionTransitionUtil.java b/src/main/java/com/google/devtools/build/lib/analysis/skylark/FunctionTransitionUtil.java
index a3b6b19..161af39 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/skylark/FunctionTransitionUtil.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/skylark/FunctionTransitionUtil.java
@@ -14,14 +14,11 @@
 
 package com.google.devtools.build.lib.analysis.skylark;
 
-import static java.nio.charset.StandardCharsets.US_ASCII;
-
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.common.io.BaseEncoding;
 import com.google.devtools.build.lib.analysis.config.BuildOptions;
 import com.google.devtools.build.lib.analysis.config.CoreOptions;
 import com.google.devtools.build.lib.analysis.config.FragmentOptions;
@@ -33,15 +30,18 @@
 import com.google.devtools.build.lib.syntax.Runtime;
 import com.google.devtools.build.lib.syntax.Runtime.NoneType;
 import com.google.devtools.build.lib.syntax.SkylarkDict;
+import com.google.devtools.build.lib.util.Fingerprint;
 import com.google.devtools.common.options.OptionDefinition;
 import com.google.devtools.common.options.OptionsParser;
 import com.google.devtools.common.options.OptionsParsingException;
 import java.lang.reflect.Field;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
+import java.util.HashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
 
 /**
  * Utility class for common work done across {@link StarlarkAttributeTransitionProvider} and {@link
@@ -50,7 +50,6 @@
 public class FunctionTransitionUtil {
 
   public static final String COMMAND_LINE_OPTION_PREFIX = "//command_line_option:";
-
   /**
    * Figure out what build settings the given transition changes and apply those changes to the
    * incoming {@link BuildOptions}. For native options, this involves a preprocess step of
@@ -210,8 +209,8 @@
    * @param buildOptionsToTransition the pre-transition build options
    * @param newValues a map of option name: option value entries to override current option values
    *     in the buildOptions param
-   * @param optionInfoMap a map of option name: option info for all native options that may be
-   *     accessed in this transition
+   * @param optionInfoMap a map of all native options (name -> OptionInfo) present in {@code
+   *     toOptions}.
    * @param starlarkTransition transition object that is being applied. Used for error reporting and
    *     checking for analysis testing
    * @return the post-transition build options
@@ -224,6 +223,7 @@
       StarlarkDefinedConfigTransition starlarkTransition)
       throws EvalException {
     BuildOptions buildOptions = buildOptionsToTransition.clone();
+    HashMap<String, Object> convertedNewValues = new HashMap<>();
     for (Map.Entry<String, Object> entry : newValues.entrySet()) {
       String optionName = entry.getKey();
       Object optionValue = entry.getValue();
@@ -234,6 +234,7 @@
                 .merge(buildOptions)
                 .addStarlarkOption(Label.parseAbsoluteUnchecked(optionName), optionValue)
                 .build();
+        convertedNewValues.put(optionName, optionValue);
       } else {
         optionName = optionName.substring(COMMAND_LINE_OPTION_PREFIX.length());
 
@@ -256,8 +257,11 @@
           FragmentOptions options = buildOptions.get(optionInfo.getOptionClass());
           if (optionValue == null || def.getType().isInstance(optionValue)) {
             field.set(options, optionValue);
+            convertedNewValues.put(entry.getKey(), optionValue);
           } else if (optionValue instanceof String) {
-            field.set(options, def.getConverter().convert((String) optionValue));
+            Object convertedValue = def.getConverter().convert((String) optionValue);
+            field.set(options, convertedValue);
+            convertedNewValues.put(entry.getKey(), convertedValue);
           } else {
             throw new EvalException(
                 starlarkTransition.getLocationForErrorReporting(),
@@ -284,43 +288,70 @@
     if (starlarkTransition.isForAnalysisTesting()) {
       buildConfigOptions.evaluatingForAnalysisTest = true;
     }
-    updateOutputDirectoryNameFragment(buildConfigOptions, newValues);
+    updateOutputDirectoryNameFragment(convertedNewValues.keySet(), optionInfoMap, buildOptions);
 
     return buildOptions;
   }
 
   /**
-   * Compute the output directory name fragment corresponding to the transition, and append it to
-   * the existing name fragment in buildConfigOptions.
+   * Compute the output directory name fragment corresponding to the new BuildOptions based on (1)
+   * the names and values of all native options previously transitioned anywhere in the build by
+   * starlark options, (2) names and values of all entries in the starlark options map.
    *
-   * @throws IllegalStateException If MD5 support is not available
+   * @param changedOptions the names of all options changed by this transition in label form e.g.
+   *     "//command_line_option:cpu" for native options and "//myapp:foo" for starlark options.
+   * @param optionInfoMap a map of all native options (name -> OptionInfo) present in {@code
+   *     toOptions}.
+   * @param toOptions the newly transitioned {@link BuildOptions} for which we need to updated
+   *     {@code transitionDirectoryNameFragment} and {@code affectedByStarlarkTransition}.
    */
+  // TODO(bazel-team): This hashes different forms of equivalent values differently though they
+  // should be the same configuration. Starlark transitions are flexible about the values they
+  // take (e.g. bool-typed options can take 0/1, True/False, "0"/"1", or "True"/"False") which
+  // makes it so that two configurations that are the same in value may hash differently.
   private static void updateOutputDirectoryNameFragment(
-      CoreOptions buildConfigOptions, Map<String, Object> transition) {
-    String transitionString = "";
-    for (Map.Entry<String, Object> entry : transition.entrySet()) {
-      transitionString += entry.getKey() + ":";
-      if (entry.getValue() != null) {
-        transitionString += entry.getValue() + "@";
+      Set<String> changedOptions, Map<String, OptionInfo> optionInfoMap, BuildOptions toOptions) {
+    CoreOptions buildConfigOptions = toOptions.get(CoreOptions.class);
+    Set<String> updatedAffectedByStarlarkTransition =
+        new TreeSet<>(buildConfigOptions.affectedByStarlarkTransition);
+    // Add newly changed native options to overall list of changed native options
+    for (String option : changedOptions) {
+      if (option.startsWith(COMMAND_LINE_OPTION_PREFIX)) {
+        updatedAffectedByStarlarkTransition.add(
+            option.substring(COMMAND_LINE_OPTION_PREFIX.length()));
       }
     }
+    buildConfigOptions.affectedByStarlarkTransition =
+        ImmutableList.sortedCopyOf(updatedAffectedByStarlarkTransition);
 
-    // TODO(waltl): for transitions that don't read settings, it is possible to precompute and
-    // reuse the MD5 digest and even the transition itself.
-    try {
-      byte[] bytes = transitionString.getBytes(US_ASCII);
-      MessageDigest md = MessageDigest.getInstance("MD5");
-      byte[] digest = md.digest(bytes);
-      String hexDigest = BaseEncoding.base16().lowerCase().encode(digest);
-
-      if (buildConfigOptions.transitionDirectoryNameFragment == null) {
-        buildConfigOptions.transitionDirectoryNameFragment = hexDigest;
-      } else {
-        buildConfigOptions.transitionDirectoryNameFragment += "-" + hexDigest;
+    // hash all relevant native option values;
+    TreeMap<String, Object> toHash = new TreeMap<>();
+    for (String nativeOption : updatedAffectedByStarlarkTransition) {
+      Object value;
+      try {
+        value =
+            optionInfoMap
+                .get(nativeOption)
+                .getDefinition()
+                .getField()
+                .get(toOptions.get(optionInfoMap.get(nativeOption).getOptionClass()));
+      } catch (IllegalAccessException e) {
+        throw new RuntimeException(
+            "IllegalAccess for option " + nativeOption + ": " + e.getMessage());
       }
-    } catch (NoSuchAlgorithmException e) {
-      throw new IllegalStateException("MD5 not available", e);
+      toHash.put(nativeOption, value);
     }
+
+    // hash all starlark options in map.
+    toOptions.getStarlarkOptions().forEach((opt, value) -> toHash.put(opt.toString(), value));
+
+    Fingerprint fp = new Fingerprint();
+    for (Map.Entry<String, Object> singleOptionAndValue : toHash.entrySet()) {
+      String toAdd = singleOptionAndValue.getKey() + "=" + singleOptionAndValue.getValue();
+      fp.addString(toAdd);
+    }
+    // Make this hash somewhat recognizable
+    buildConfigOptions.transitionDirectoryNameFragment = "ST-" + fp.hexDigestAndReset();
   }
 
   /** Stores option info useful to a FunctionSplitTransition. */
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkTransition.java b/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkTransition.java
index f7e6505..2d1472a 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkTransition.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkTransition.java
@@ -46,6 +46,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
 import javax.annotation.Nullable;
 
@@ -542,6 +543,18 @@
     return Objects.hashCode(starlarkDefinedConfigTransition);
   }
 
+  /** Given a transition, figures out if it composes any Starlark transitions. */
+  public static boolean doesStarlarkTransition(ConfigurationTransition root)
+      throws TransitionException {
+    AtomicBoolean doesStarlarkTransition = new AtomicBoolean(false);
+    root.visit(
+        (StarlarkTransitionVisitor)
+            transition -> {
+              doesStarlarkTransition.set(true);
+            });
+    return doesStarlarkTransition.get();
+  }
+
   @FunctionalInterface
   // This is only used in this class to handle the cast and the exception
   @SuppressWarnings("FunctionalInterfaceMethodChanged")
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/StarlarkAttrTransitionProviderTest.java b/src/test/java/com/google/devtools/build/lib/analysis/StarlarkAttrTransitionProviderTest.java
index 55b5464..6f2d15e 100644
--- a/src/test/java/com/google/devtools/build/lib/analysis/StarlarkAttrTransitionProviderTest.java
+++ b/src/test/java/com/google/devtools/build/lib/analysis/StarlarkAttrTransitionProviderTest.java
@@ -20,6 +20,9 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Maps;
+import com.google.devtools.build.lib.analysis.StarlarkRuleTransitionProviderTest.DummyTestLoader;
+import com.google.devtools.build.lib.analysis.config.CoreOptions;
 import com.google.devtools.build.lib.analysis.util.BuildViewTestCase;
 import com.google.devtools.build.lib.cmdline.Label;
 import com.google.devtools.build.lib.packages.Provider;
@@ -27,6 +30,8 @@
 import com.google.devtools.build.lib.packages.SkylarkProvider;
 import com.google.devtools.build.lib.packages.StructImpl;
 import com.google.devtools.build.lib.packages.util.BazelMockAndroidSupport;
+import com.google.devtools.build.lib.testutil.TestRuleClassProvider;
+import com.google.devtools.build.lib.util.Fingerprint;
 import java.util.List;
 import java.util.stream.Collectors;
 import org.junit.Before;
@@ -38,6 +43,14 @@
 @RunWith(JUnit4.class)
 public class StarlarkAttrTransitionProviderTest extends BuildViewTestCase {
 
+  @Override
+  protected ConfiguredRuleClassProvider getRuleClassProvider() {
+    ConfiguredRuleClassProvider.Builder builder = new ConfiguredRuleClassProvider.Builder();
+    TestRuleClassProvider.addStandardRules(builder);
+    builder.addConfigurationFragment(new DummyTestLoader());
+    return builder.build();
+  }
+
   @Before
   public void setupMyInfo() throws Exception {
     scratch.file("myinfo/myinfo.bzl", "MyInfo = provider()");
@@ -58,7 +71,7 @@
         "package_group(",
         "    name = 'function_transition_whitelist',",
         "    packages = [",
-        "        '//test/skylark/...',",
+        "        '//test/...',",
         "    ],",
         ")");
   }
@@ -209,7 +222,7 @@
     writeWhitelistFile();
     getAnalysisMock().ccSupport().setupCcToolchainConfigForCpu(mockToolsConfig, "armeabi-v7a");
     scratch.file(
-        "test/not_whitelisted/my_rule.bzl",
+        "not_whitelisted/my_rule.bzl",
         "load('//myinfo:myinfo.bzl', 'MyInfo')",
         "def transition_func(settings, attr):",
         "  return [",
@@ -232,13 +245,13 @@
         "    ),",
         "  })");
     scratch.file(
-        "test/not_whitelisted/BUILD",
-        "load('//test/not_whitelisted:my_rule.bzl', 'my_rule')",
+        "not_whitelisted/BUILD",
+        "load('//not_whitelisted:my_rule.bzl', 'my_rule')",
         "my_rule(name = 'test', dep = ':main')",
         "cc_binary(name = 'main', srcs = ['main.c'])");
 
     reporter.removeHandler(failFastHandler);
-    getConfiguredTarget("//test/not_whitelisted:test");
+    getConfiguredTarget("//not_whitelisted:test");
     assertContainsEvent("Non-whitelisted use of Starlark transition");
   }
 
@@ -523,7 +536,7 @@
         "def transition_func(settings, attr):",
         "  return {'//command_line_option:cpu': 'k8'}",
         "my_transition = transition(implementation = transition_func,",
-        "  inputs = ['//command_line_option:foo', '//command_line_option:bar'],",
+        "  inputs = ['//command_line_option:foop', '//command_line_option:barp'],",
         "  outputs = ['//command_line_option:cpu'])",
         "def impl(ctx): ",
         "  return []",
@@ -545,7 +558,7 @@
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//test/skylark:test");
     assertContainsEvent(
-        "transition inputs [//command_line_option:foo, //command_line_option:bar] "
+        "transition inputs [//command_line_option:foop, //command_line_option:barp] "
             + "do not correspond to valid settings");
   }
 
@@ -860,6 +873,635 @@
         .isEqualTo(42);
   }
 
+  private CoreOptions getCoreOptions(ConfiguredTarget target) {
+    return getConfiguration(target).getOptions().get(CoreOptions.class);
+  }
+
+  @Test
+  public void testOutputDirHash_multipleNativeOptionTransitions() throws Exception {
+    writeWhitelistFile();
+    scratch.file(
+        "test/transitions.bzl",
+        "def _foo_impl(settings, attr):",
+        "  return {'//command_line_option:foo': 'foosball'}",
+        "foo_transition = transition(implementation = _foo_impl, inputs = [],",
+        "  outputs = ['//command_line_option:foo'])",
+        "def _bar_impl(settings, attr):",
+        "  return {'//command_line_option:bar': 'barsball'}",
+        "bar_transition = transition(implementation = _bar_impl, inputs = [],",
+        "  outputs = ['//command_line_option:bar'])");
+    scratch.file(
+        "test/rules.bzl",
+        "load('//myinfo:myinfo.bzl', 'MyInfo')",
+        "load('//test:transitions.bzl', 'foo_transition', 'bar_transition')",
+        "def _impl(ctx):",
+        "  return MyInfo(dep = ctx.attr.dep)",
+        "my_rule = rule(",
+        "  implementation = _impl,",
+        "  cfg = foo_transition,",
+        "  attrs = {",
+        "    'dep': attr.label(cfg = bar_transition), ",
+        "    '_whitelist_function_transition': attr.label(",
+        "        default = '//tools/whitelists/function_transition_whitelist',",
+        "    ),",
+        "  })",
+        "def _basic_impl(ctx):",
+        "  return []",
+        "simple = rule(_basic_impl)");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:rules.bzl', 'my_rule', 'simple')",
+        "my_rule(name = 'test', dep = ':dep')",
+        "simple(name = 'dep')");
+
+    ConfiguredTarget test = getConfiguredTarget("//test");
+
+    List<String> affectedOptions = getCoreOptions(test).affectedByStarlarkTransition;
+
+    assertThat(affectedOptions).containsExactly("foo");
+
+    @SuppressWarnings("unchecked")
+    ConfiguredTarget dep =
+        Iterables.getOnlyElement(
+            (List<ConfiguredTarget>) getMyInfoFromTarget(test).getValue("dep"));
+
+    affectedOptions = getCoreOptions(dep).affectedByStarlarkTransition;
+
+    assertThat(affectedOptions).containsExactly("foo", "bar");
+
+    assertThat(getCoreOptions(test).transitionDirectoryNameFragment)
+        .isEqualTo("ST-" + new Fingerprint().addString("foo=foosball").hexDigestAndReset());
+
+    assertThat(getCoreOptions(dep).transitionDirectoryNameFragment)
+        .isEqualTo(
+            "ST-"
+                + new Fingerprint()
+                    .addString("bar=barsball")
+                    .addString("foo=foosball")
+                    .hexDigestAndReset());
+  }
+
+  // Test that a no-op starlark transition to an already starlark transitioned configuration
+  // results in the same configuration.
+  @Test
+  public void testOutputDirHash_noop_changeToSameState() throws Exception {
+    writeWhitelistFile();
+    scratch.file(
+        "test/transitions.bzl",
+        "def _bar_impl(settings, attr):",
+        "  return {'//test:bar': 'barsball'}",
+        "bar_transition = transition(implementation = _bar_impl, inputs = [],",
+        "  outputs = ['//test:bar'])");
+    scratch.file(
+        "test/rules.bzl",
+        "load('//myinfo:myinfo.bzl', 'MyInfo')",
+        "load('//test:transitions.bzl', 'bar_transition')",
+        "def _impl(ctx):",
+        "  return MyInfo(dep = ctx.attr.dep)",
+        "my_rule = rule(",
+        "  implementation = _impl,",
+        "  cfg = bar_transition,",
+        "  attrs = {",
+        "    'dep': attr.label(cfg = bar_transition), ",
+        "    '_whitelist_function_transition': attr.label(",
+        "        default = '//tools/whitelists/function_transition_whitelist',",
+        "    ),",
+        "  })",
+        "def _basic_impl(ctx):",
+        "  return []",
+        "string_flag = rule(",
+        "  implementation = _basic_impl,",
+        "  build_setting = config.string(flag = True),",
+        ")",
+        "simple = rule(_basic_impl)");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:rules.bzl', 'my_rule', 'string_flag', 'simple')",
+        "my_rule(name = 'test', dep = ':dep')",
+        "simple(name = 'dep')",
+        "string_flag(name = 'bar', build_setting_default = '')");
+
+    ConfiguredTarget test = getConfiguredTarget("//test");
+
+    @SuppressWarnings("unchecked")
+    ConfiguredTarget dep =
+        Iterables.getOnlyElement(
+            (List<ConfiguredTarget>) getMyInfoFromTarget(test).getValue("dep"));
+
+    assertThat(getCoreOptions(test).transitionDirectoryNameFragment)
+        .isEqualTo(getCoreOptions(dep).transitionDirectoryNameFragment);
+  }
+
+  @Test
+  public void testOutputDirHash_noop_emptyReturns() throws Exception {
+    writeWhitelistFile();
+    scratch.file(
+        "test/transitions.bzl",
+        "def _bar_impl(settings, attr):",
+        "  return {}",
+        "bar_transition = transition(implementation = _bar_impl, inputs = [],",
+        "  outputs = [])");
+    scratch.file(
+        "test/rules.bzl",
+        "load('//myinfo:myinfo.bzl', 'MyInfo')",
+        "load('//test:transitions.bzl', 'bar_transition')",
+        "def _impl(ctx):",
+        "  return MyInfo(dep = ctx.attr.dep)",
+        "my_rule = rule(",
+        "  implementation = _impl,",
+        "  cfg = bar_transition,",
+        "  attrs = {",
+        "    'dep': attr.label(cfg = bar_transition), ",
+        "    '_whitelist_function_transition': attr.label(",
+        "        default = '//tools/whitelists/function_transition_whitelist',",
+        "    ),",
+        "  })",
+        "def _basic_impl(ctx):",
+        "  return []",
+        "simple = rule(_basic_impl)");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:rules.bzl', 'my_rule', 'simple')",
+        "my_rule(name = 'test', dep = ':dep')",
+        "simple(name = 'dep')");
+
+    ConfiguredTarget test = getConfiguredTarget("//test");
+
+    @SuppressWarnings("unchecked")
+    ConfiguredTarget dep =
+        Iterables.getOnlyElement(
+            (List<ConfiguredTarget>) getMyInfoFromTarget(test).getValue("dep"));
+
+    assertThat(getCoreOptions(test).transitionDirectoryNameFragment)
+        .isEqualTo(getCoreOptions(dep).transitionDirectoryNameFragment);
+  }
+
+  // Test that a no-op starlark transition to the top level configuration results in a
+  // different configuration.
+  // TODO(bazel-team): This can be optimized. Make these the same configuration.
+  @Test
+  public void testOutputDirHash_noop_changeToDifferentStateAsTopLevel() throws Exception {
+    writeWhitelistFile();
+    scratch.file(
+        "test/transitions.bzl",
+        "def _bar_impl(settings, attr):",
+        "  return {'//test:bar': 'barsball'}",
+        "bar_transition = transition(implementation = _bar_impl, inputs = [],",
+        "  outputs = ['//test:bar'])");
+    scratch.file(
+        "test/rules.bzl",
+        "load('//myinfo:myinfo.bzl', 'MyInfo')",
+        "load('//test:transitions.bzl', 'bar_transition')",
+        "def _impl(ctx):",
+        "  return MyInfo(dep = ctx.attr.dep)",
+        "my_rule = rule(",
+        "  implementation = _impl,",
+        "  attrs = {",
+        "    'dep': attr.label(cfg = bar_transition), ",
+        "    '_whitelist_function_transition': attr.label(",
+        "        default = '//tools/whitelists/function_transition_whitelist',",
+        "    ),",
+        "  })",
+        "def _basic_impl(ctx):",
+        "  return []",
+        "string_flag = rule(",
+        "  implementation = _basic_impl,",
+        "  build_setting = config.string(flag = True),",
+        ")",
+        "simple = rule(_basic_impl)");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:rules.bzl', 'my_rule', 'string_flag', 'simple')",
+        "my_rule(name = 'test', dep = ':dep')",
+        "simple(name = 'dep')",
+        "string_flag(name = 'bar', build_setting_default = '')");
+
+    // Use configuration that is same as what the transition will change.
+    useConfiguration(ImmutableMap.of("//test:bar", "barsball"));
+
+    ConfiguredTarget test = getConfiguredTarget("//test");
+
+    @SuppressWarnings("unchecked")
+    ConfiguredTarget dep =
+        Iterables.getOnlyElement(
+            (List<ConfiguredTarget>) getMyInfoFromTarget(test).getValue("dep"));
+
+    assertThat(getCoreOptions(test).transitionDirectoryNameFragment).isNull();
+    assertThat(getCoreOptions(dep).transitionDirectoryNameFragment).isNotNull();
+  }
+
+  // Test that setting all starlark options back to default != null hash of top level.
+  // We could set some starlark options on the command line but we don't count this as a starlark
+  // transition to the command line configuration will always have a null values for
+  // {@code transitionDirectoryNameFragment}.
+  //
+  // e.g. for a build setting //foo whose default value is "foop" the following sequence
+  //
+  // (CommandLine) //foo=blah -> (StarlarkTransition) //foo=foop
+  //
+  // must create a non-null hash for after the StarlarkTransition even though later on we empty
+  // the default out of the starlark map (In StarlarkTransition#validate)
+  // TODO(bazel-team): This can be optimized. Make these the same configuration.
+  @Test
+  public void testOutputDirHash_multipleStarlarkOptionTransitions_backToDefaultCommandLine()
+      throws Exception {
+    writeWhitelistFile();
+    scratch.file(
+        "test/transitions.bzl",
+        "def _foo_two_impl(settings, attr):",
+        "  return {'//test:foo': 'foosballerina'}",
+        "foo_two_transition = transition(implementation = _foo_two_impl, inputs = [],",
+        "  outputs = ['//test:foo'])");
+    scratch.file(
+        "test/rules.bzl",
+        "load('//myinfo:myinfo.bzl', 'MyInfo')",
+        "load('//test:transitions.bzl', 'foo_two_transition')",
+        "def _impl(ctx):",
+        "  return MyInfo(dep = ctx.attr.dep)",
+        "my_rule = rule(",
+        "  implementation = _impl,",
+        "  attrs = {",
+        "    'dep': attr.label(cfg = foo_two_transition), ",
+        "    '_whitelist_function_transition': attr.label(",
+        "        default = '//tools/whitelists/function_transition_whitelist',",
+        "    ),",
+        "  })",
+        "def _basic_impl(ctx):",
+        "  return []",
+        "string_flag = rule(",
+        "  implementation = _basic_impl,",
+        "  build_setting = config.string(flag = True),",
+        ")",
+        "simple = rule(_basic_impl)");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:rules.bzl', 'my_rule', 'string_flag', 'simple')",
+        "my_rule(name = 'test', dep = ':dep')",
+        "simple(name = 'dep')",
+        "string_flag(name = 'foo', build_setting_default = 'foosballerina')");
+
+    useConfiguration(ImmutableMap.of("//test:foo", "foosball"));
+
+    @SuppressWarnings("unchecked")
+    ConfiguredTarget dep =
+        Iterables.getOnlyElement(
+            (List<ConfiguredTarget>)
+                getMyInfoFromTarget(getConfiguredTarget("//test")).getValue("dep"));
+
+    assertThat(getCoreOptions(dep).transitionDirectoryNameFragment).isNotNull();
+  }
+
+  // Test that setting a starlark option to default (if it was already at default) doesn't
+  // produce the same hash. This is because we do hashing  before scrubbing default values
+  // out of {@code BuildOptions}.
+  // TODO(bazel-team): This can be optimized. Make these the same configuration.
+  @Test
+  public void testOutputDirHash_multipleStarlarkOptionTransitions_backToDefault() throws Exception {
+    writeWhitelistFile();
+    scratch.file(
+        "test/transitions.bzl",
+        "def _foo_two_impl(settings, attr):",
+        "  return {'//test:foo': 'foosballerina'}",
+        "foo_two_transition = transition(implementation = _foo_two_impl, inputs = [],",
+        "  outputs = ['//test:foo'])");
+    scratch.file(
+        "test/rules.bzl",
+        "load('//myinfo:myinfo.bzl', 'MyInfo')",
+        "load('//test:transitions.bzl', 'foo_two_transition')",
+        "def _impl(ctx):",
+        "  return MyInfo(dep = ctx.attr.dep)",
+        "my_rule = rule(",
+        "  implementation = _impl,",
+        "  attrs = {",
+        "    'dep': attr.label(cfg = foo_two_transition), ",
+        "    '_whitelist_function_transition': attr.label(",
+        "        default = '//tools/whitelists/function_transition_whitelist',",
+        "    ),",
+        "  })",
+        "def _basic_impl(ctx):",
+        "  return []",
+        "string_flag = rule(",
+        "  implementation = _basic_impl,",
+        "  build_setting = config.string(flag = True),",
+        ")",
+        "simple = rule(_basic_impl)");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:rules.bzl', 'my_rule', 'string_flag', 'simple')",
+        "my_rule(name = 'test', dep = ':dep')",
+        "simple(name = 'dep')",
+        "string_flag(name = 'foo', build_setting_default = 'foosballerina')");
+
+    ConfiguredTarget test = getConfiguredTarget("//test");
+
+    @SuppressWarnings("unchecked")
+    ConfiguredTarget dep =
+        Iterables.getOnlyElement(
+            (List<ConfiguredTarget>) getMyInfoFromTarget(test).getValue("dep"));
+
+    assertThat(getCoreOptions(dep).transitionDirectoryNameFragment)
+        .isNotEqualTo(getCoreOptions(test).transitionDirectoryNameFragment);
+  }
+
+  /** See comment above {@link FunctionTransitionUtil#updateOutputDirectoryNameFragment} */
+  // TODO(bazel-team): This can be optimized. Make these the same configuration.
+  @Test
+  public void testOutputDirHash_starlarkOption_differentBoolRepresentationsNotEquals()
+      throws Exception {
+    writeWhitelistFile();
+    scratch.file(
+        "test/transitions.bzl",
+        "def _foo_impl(settings, attr):",
+        "  return {'//test:foo': 1}",
+        "foo_transition = transition(implementation = _foo_impl, inputs = [],",
+        "  outputs = ['//test:foo'])",
+        "def _foo_two_impl(settings, attr):",
+        "  return {'//test:foo': True}",
+        "foo_two_transition = transition(implementation = _foo_two_impl, inputs = [],",
+        "  outputs = ['//test:foo'])");
+    scratch.file(
+        "test/rules.bzl",
+        "load('//myinfo:myinfo.bzl', 'MyInfo')",
+        "load('//test:transitions.bzl', 'foo_transition', 'foo_two_transition')",
+        "def _impl(ctx):",
+        "  return MyInfo(dep = ctx.attr.dep)",
+        "my_rule = rule(",
+        "  implementation = _impl,",
+        "  cfg = foo_transition,",
+        "  attrs = {",
+        "    'dep': attr.label(cfg = foo_two_transition), ",
+        "    '_whitelist_function_transition': attr.label(",
+        "        default = '//tools/whitelists/function_transition_whitelist',",
+        "    ),",
+        "  })",
+        "def _basic_impl(ctx):",
+        "  return []",
+        "bool_flag = rule(",
+        "  implementation = _basic_impl,",
+        "  build_setting = config.bool(flag = True),",
+        ")",
+        "simple = rule(_basic_impl)");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:rules.bzl', 'my_rule', 'bool_flag', 'simple')",
+        "my_rule(name = 'test', dep = ':dep')",
+        "simple(name = 'dep')",
+        "bool_flag(name = 'foo', build_setting_default = False)");
+
+    ConfiguredTarget test = getConfiguredTarget("//test");
+
+    @SuppressWarnings("unchecked")
+    ConfiguredTarget dep =
+        Iterables.getOnlyElement(
+            (List<ConfiguredTarget>) getMyInfoFromTarget(test).getValue("dep"));
+
+    assertThat(getCoreOptions(test).transitionDirectoryNameFragment)
+        .isEqualTo("ST-" + new Fingerprint().addString("//test:foo=1").hexDigestAndReset());
+    assertThat(getCoreOptions(dep).transitionDirectoryNameFragment)
+        .isEqualTo("ST-" + new Fingerprint().addString("//test:foo=true").hexDigestAndReset());
+  }
+
+  @Test
+  public void testOutputDirHash_nativeOption_differentBoolRepresentationsEquals() throws Exception {
+    writeWhitelistFile();
+    scratch.file(
+        "test/transitions.bzl",
+        "def _bool_impl(settings, attr):",
+        "  return {'//command_line_option:bool': '1'}",
+        "bool_transition = transition(implementation = _bool_impl, inputs = [],",
+        "  outputs = ['//command_line_option:bool'])",
+        "def _bool_two_impl(settings, attr):",
+        "  return {'//command_line_option:bool': 'true'}",
+        "bool_two_transition = transition(implementation = _bool_two_impl, inputs = [],",
+        "  outputs = ['//command_line_option:bool'])");
+    scratch.file(
+        "test/rules.bzl",
+        "load('//myinfo:myinfo.bzl', 'MyInfo')",
+        "load('//test:transitions.bzl', 'bool_transition', 'bool_two_transition')",
+        "def _impl(ctx):",
+        "  return MyInfo(dep = ctx.attr.dep)",
+        "my_rule = rule(",
+        "  implementation = _impl,",
+        "  cfg = bool_transition,",
+        "  attrs = {",
+        "    'dep': attr.label(cfg = bool_two_transition), ",
+        "    '_whitelist_function_transition': attr.label(",
+        "        default = '//tools/whitelists/function_transition_whitelist',",
+        "    ),",
+        "  })",
+        "def _basic_impl(ctx):",
+        "  return []",
+        "simple = rule(_basic_impl)");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:rules.bzl', 'my_rule', 'simple')",
+        "my_rule(name = 'test', dep = ':dep')",
+        "simple(name = 'dep')");
+
+    ConfiguredTarget test = getConfiguredTarget("//test");
+
+    @SuppressWarnings("unchecked")
+    ConfiguredTarget dep =
+        Iterables.getOnlyElement(
+            (List<ConfiguredTarget>) getMyInfoFromTarget(test).getValue("dep"));
+
+    assertThat(getCoreOptions(test).transitionDirectoryNameFragment)
+        .isEqualTo(getCoreOptions(dep).transitionDirectoryNameFragment);
+  }
+
+  @Test
+  public void testOutputDirHash_multipleStarlarkTransitions() throws Exception {
+    writeWhitelistFile();
+    scratch.file(
+        "test/transitions.bzl",
+        "def _foo_impl(settings, attr):",
+        "  return {'//test:foo': 'foosball'}",
+        "foo_transition = transition(implementation = _foo_impl, inputs = [],",
+        "  outputs = ['//test:foo'])",
+        "def _bar_impl(settings, attr):",
+        "  return {'//test:bar': 'barsball'}",
+        "bar_transition = transition(implementation = _bar_impl, inputs = [],",
+        "  outputs = ['//test:bar'])");
+    scratch.file(
+        "test/rules.bzl",
+        "load('//myinfo:myinfo.bzl', 'MyInfo')",
+        "load('//test:transitions.bzl', 'foo_transition', 'bar_transition')",
+        "def _impl(ctx):",
+        "  return MyInfo(dep = ctx.attr.dep)",
+        "my_rule = rule(",
+        "  implementation = _impl,",
+        "  cfg = foo_transition,",
+        "  attrs = {",
+        "    'dep': attr.label(cfg = bar_transition), ",
+        "    '_whitelist_function_transition': attr.label(",
+        "        default = '//tools/whitelists/function_transition_whitelist',",
+        "    ),",
+        "  })",
+        "def _basic_impl(ctx):",
+        "  return []",
+        "string_flag = rule(",
+        "  implementation = _basic_impl,",
+        "  build_setting = config.string(flag = True),",
+        ")",
+        "simple = rule(_basic_impl)");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:rules.bzl', 'my_rule', 'string_flag', 'simple')",
+        "my_rule(name = 'test', dep = ':dep')",
+        "simple(name = 'dep')",
+        "string_flag(name = 'foo', build_setting_default = '')",
+        "string_flag(name = 'bar', build_setting_default = '')");
+
+    ConfiguredTarget test = getConfiguredTarget("//test");
+
+    @SuppressWarnings("unchecked")
+    ConfiguredTarget dep =
+        Iterables.getOnlyElement(
+            (List<ConfiguredTarget>) getMyInfoFromTarget(test).getValue("dep"));
+
+    List<String> affectedOptions =
+        getConfiguration(dep).getOptions().get(CoreOptions.class).affectedByStarlarkTransition;
+
+    // Assert that affectedOptions is empty but final fragment is still different.
+    assertThat(affectedOptions).isEmpty();
+    assertThat(getCoreOptions(test).transitionDirectoryNameFragment)
+        .isEqualTo("ST-" + new Fingerprint().addString("//test:foo=foosball").hexDigestAndReset());
+    assertThat(getCoreOptions(dep).transitionDirectoryNameFragment)
+        .isEqualTo(
+            "ST-"
+                + new Fingerprint()
+                    .addString("//test:bar=barsball")
+                    .addString("//test:foo=foosball")
+                    .hexDigestAndReset());
+  }
+
+  // This test is massive but mostly exists to ensure that all the parts are working together
+  // properly amidst multiple complicated transitions.
+  @Test
+  public void testOutputDirHash_multipleMixedTransitions() throws Exception {
+    writeWhitelistFile();
+    scratch.file(
+        "test/transitions.bzl",
+        "def _foo_impl(settings, attr):",
+        "  return {'//command_line_option:foo': 'foosball'}",
+        "foo_transition = transition(implementation = _foo_impl, inputs = [],",
+        "  outputs = ['//command_line_option:foo'])",
+        "def _bar_impl(settings, attr):",
+        "  return {'//command_line_option:bar': 'barsball'}",
+        "bar_transition = transition(implementation = _bar_impl, inputs = [],",
+        "  outputs = ['//command_line_option:bar'])",
+        "def _zee_impl(settings, attr):",
+        "  return {'//test:zee': 'zeesball'}",
+        "zee_transition = transition(implementation = _zee_impl, inputs = [],",
+        "  outputs = ['//test:zee'])",
+        "def _xan_impl(settings, attr):",
+        "  return {'//test:xan': 'xansball'}",
+        "xan_transition = transition(implementation = _xan_impl, inputs = [],",
+        "  outputs = ['//test:xan'])");
+    scratch.file(
+        "test/rules.bzl",
+        "load('//myinfo:myinfo.bzl', 'MyInfo')",
+        "load(",
+        "  '//test:transitions.bzl',",
+        "  'foo_transition',",
+        "  'bar_transition',",
+        "  'zee_transition',",
+        "  'xan_transition')",
+        "def _impl_a(ctx):",
+        "  return MyInfo(dep = ctx.attr.dep)",
+        "my_rule_a = rule(",
+        "  implementation = _impl_a,",
+        "  cfg = foo_transition,", // transition #1
+        "  attrs = {",
+        "    'dep': attr.label(cfg = zee_transition), ", // transition #2
+        "    '_whitelist_function_transition': attr.label(",
+        "        default = '//tools/whitelists/function_transition_whitelist',",
+        "    ),",
+        "  })",
+        "def _impl_b(ctx):",
+        "  return MyInfo(dep = ctx.attr.dep)",
+        "my_rule_b = rule(",
+        "  implementation = _impl_b,",
+        "  cfg = bar_transition,", // transition #3
+        "  attrs = {",
+        "    'dep': attr.label(cfg = xan_transition), ", // transition #4
+        "    '_whitelist_function_transition': attr.label(",
+        "        default = '//tools/whitelists/function_transition_whitelist',",
+        "    ),",
+        "  })",
+        "def _basic_impl(ctx):",
+        "  return []",
+        "string_flag = rule(",
+        "  implementation = _basic_impl,",
+        "  build_setting = config.string(flag = True),",
+        ")",
+        "simple = rule(_basic_impl)");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:rules.bzl', 'my_rule_a', 'my_rule_b', 'string_flag', 'simple')",
+        "my_rule_a(name = 'top', dep = ':middle')",
+        "my_rule_b(name = 'middle', dep = 'bottom')",
+        "simple(name = 'bottom')",
+        "string_flag(name = 'zee', build_setting_default = '')",
+        "string_flag(name = 'xan', build_setting_default = '')");
+
+    // test:top (foo_transition)
+    ConfiguredTarget top = getConfiguredTarget("//test:top");
+
+    List<String> affectedOptionsTop =
+        getConfiguration(top).getOptions().get(CoreOptions.class).affectedByStarlarkTransition;
+
+    assertThat(affectedOptionsTop).containsExactly("foo");
+    assertThat(getConfiguration(top).getOptions().getStarlarkOptions()).isEmpty();
+    assertThat(getCoreOptions(top).transitionDirectoryNameFragment)
+        .isEqualTo("ST-" + new Fingerprint().addString("foo=foosball").hexDigestAndReset());
+
+    // test:middle (foo_transition, zee_transition, bar_transition)
+    @SuppressWarnings("unchecked")
+    ConfiguredTarget middle =
+        Iterables.getOnlyElement((List<ConfiguredTarget>) getMyInfoFromTarget(top).getValue("dep"));
+
+    List<String> affectedOptionsMiddle =
+        getConfiguration(middle).getOptions().get(CoreOptions.class).affectedByStarlarkTransition;
+
+    assertThat(affectedOptionsMiddle).containsExactly("foo", "bar");
+    assertThat(getConfiguration(middle).getOptions().getStarlarkOptions().entrySet())
+        .containsExactly(
+            Maps.immutableEntry(Label.parseAbsoluteUnchecked("//test:zee"), "zeesball"));
+    assertThat(getCoreOptions(middle).transitionDirectoryNameFragment)
+        .isEqualTo(
+            "ST-"
+                + new Fingerprint()
+                    .addString("//test:zee=zeesball")
+                    .addString("bar=barsball")
+                    .addString("foo=foosball")
+                    .hexDigestAndReset());
+
+    // test:bottom (foo_transition, zee_transition, bar_transition, xan_transition)
+    @SuppressWarnings("unchecked")
+    ConfiguredTarget bottom =
+        Iterables.getOnlyElement(
+            (List<ConfiguredTarget>) getMyInfoFromTarget(middle).getValue("dep"));
+
+    List<String> affectedOptionsBottom =
+        getConfiguration(bottom).getOptions().get(CoreOptions.class).affectedByStarlarkTransition;
+
+    assertThat(affectedOptionsBottom).containsExactly("foo", "bar");
+    assertThat(getConfiguration(bottom).getOptions().getStarlarkOptions().entrySet())
+        .containsExactly(
+            Maps.immutableEntry(Label.parseAbsoluteUnchecked("//test:zee"), "zeesball"),
+            Maps.immutableEntry(Label.parseAbsoluteUnchecked("//test:xan"), "xansball"));
+    assertThat(getCoreOptions(bottom).transitionDirectoryNameFragment)
+        .isEqualTo(
+            "ST-"
+                + new Fingerprint()
+                    .addString("//test:xan=xansball")
+                    .addString("//test:zee=zeesball")
+                    .addString("bar=barsball")
+                    .addString("foo=foosball")
+                    .hexDigestAndReset());
+  }
+
   @Test
   public void testTransitionOnBuildSetting_badValue() throws Exception {
     setSkylarkSemanticsOptions(
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/StarlarkRuleTransitionProviderTest.java b/src/test/java/com/google/devtools/build/lib/analysis/StarlarkRuleTransitionProviderTest.java
index 2670404..95e7f02 100644
--- a/src/test/java/com/google/devtools/build/lib/analysis/StarlarkRuleTransitionProviderTest.java
+++ b/src/test/java/com/google/devtools/build/lib/analysis/StarlarkRuleTransitionProviderTest.java
@@ -54,10 +54,34 @@
         effectTags = {OptionEffectTag.NO_OP},
         help = "An option that is sometimes set to null.")
     public Label nullable;
+
+    @Option(
+        name = "foo",
+        defaultValue = "",
+        documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+        effectTags = {OptionEffectTag.NO_OP},
+        help = "A regular string-typed option")
+    public String foo;
+
+    @Option(
+        name = "bar",
+        defaultValue = "",
+        documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+        effectTags = {OptionEffectTag.NO_OP},
+        help = "A regular string-typed option")
+    public String bar;
+
+    @Option(
+        name = "bool",
+        defaultValue = "false",
+        documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+        effectTags = {OptionEffectTag.NO_OP},
+        help = "A regular bool-typed option")
+    public boolean bool;
   }
 
   /** Loads a new {link @DummyTestFragment} instance. */
-  private static class DummyTestLoader implements ConfigurationFragmentFactory {
+  protected static class DummyTestLoader implements ConfigurationFragmentFactory {
 
     @Override
     public Fragment create(BuildOptions buildOptions) throws InvalidConfigurationException {
diff --git a/src/test/shell/integration/starlark_configurations_test.sh b/src/test/shell/integration/starlark_configurations_test.sh
index 48c8813..a8697d4 100755
--- a/src/test/shell/integration/starlark_configurations_test.sh
+++ b/src/test/shell/integration/starlark_configurations_test.sh
@@ -454,4 +454,72 @@
   expect_log "value=command_line_val"
 }
 
+function test_output_same_config_as_generating_target() {
+  local -r pkg=$FUNCNAME
+  mkdir -p $pkg
+
+  rm -rf tools/whitelists/function_transition_whitelist
+  mkdir -p tools/whitelists/function_transition_whitelist
+  cat > tools/whitelists/function_transition_whitelist/BUILD <<EOF
+package_group(
+    name = "function_transition_whitelist",
+    packages = [
+        "//...",
+    ],
+)
+EOF
+
+  cat > $pkg/rules.bzl <<EOF
+def _rule_class_transition_impl(settings, attr):
+    return {
+        "//command_line_option:test_arg": ["blah"]
+    }
+
+_rule_class_transition = transition(
+    implementation = _rule_class_transition_impl,
+    inputs = [],
+    outputs = [
+        "//command_line_option:test_arg",
+    ],
+)
+
+def _rule_class_transition_rule_impl(ctx):
+    ctx.actions.write(ctx.outputs.artifact, "hello\n")
+    return [DefaultInfo(files = depset([ctx.outputs.artifact]))]
+
+rule_class_transition_rule = rule(
+    _rule_class_transition_rule_impl,
+    cfg = _rule_class_transition,
+    attrs = {
+        "_whitelist_function_transition": attr.label(default = "//tools/whitelists/function_transition_whitelist"),
+    },
+    outputs = {"artifact": "%{name}.output"},
+)
+EOF
+
+  cat > $pkg/BUILD <<EOF
+load("//$pkg:rules.bzl", "rule_class_transition_rule")
+
+rule_class_transition_rule(name = "with_rule_class_transition")
+EOF
+
+  bazel build //$pkg:with_rule_class_transition.output > output 2>"$TEST_log" || fail "Expected success"
+
+  bazel cquery "deps(//$pkg:with_rule_class_transition.output, 1)" > output 2>"$TEST_log" || fail "Expected success"
+
+  assert_contains "//$pkg:with_rule_class_transition.output " output
+  assert_contains "//$pkg:with_rule_class_transition " output
+
+  # Find the lines of output for the output target and the generating target
+  OUTPUT=$(grep "//$pkg:with_rule_class_transition.output " output)
+  TARGET=$(grep "//$pkg:with_rule_class_transition " output)
+
+  # Trim to just configuration
+  OUTPUT_CONFIG=${OUTPUT/"//$pkg:with_rule_class_transition.output "}
+  TARGET_CONFIG=${TARGET/"//$pkg:with_rule_class_transition "}
+
+  # Confirm same configuration
+  assert_equals $OUTPUT_CONFIG $TARGET_CONFIG
+}
+
 run_suite "${PRODUCT_NAME} starlark configurations tests"