Allow native.existing_rule and native.existing_rules to return a lightweight view.

Experimental implementation of
https://github.com/bazelbuild/proposals/blob/main/designs/2021-06-15-improving-native.existing_rules.md

Gated behind the --experimental_existing_rules_immutable_view flag. See
https://github.com/bazelbuild/bazel/issues/13907

To check the performance impact, I queried a toy package that in a loop, creates
a genrule and then on each loop iteration performs one of the following tasks:

* checks if rule with a given name existed in `existing_rules()` ("in")
* for each rule in `existing_rules()`, checks if an attribute with a given name exists ("shallow iteration")
* for 50% of rules in `existing_rules()`, checks if a particular attribute value exists under any name ("50% deep iteration");
* iterates every rule name, attribute name, and attribute value ("deep iteration");

Query time in seconds without --experimental_existing_rules_immutable_view:

```
n	in	shallow 50%deep	deep
1000	2.73	2.84	3.36	3.61
2000	8.42	9.07	10.97	11.62
4000	30.95	33.81	40.88	44.17
```

With --experimental_existing_rules_immutable_view:

```
n	in	shallow 50%deep	deep
1000	0.40	0.64	2.58	4.19
2000	0.43	1.08	7.96	13.64
4000	0.51	2.59	28.83	52.99
```

In other words, in the unusual case where we examine every attribute value
of every existing rule, the overhead of managing immutable views would cause
a 15-20% slowdown. In any other situation, where we don't need to materialize
every rule's attribute values, the immutable view approach yields a
substantial performance improvement.

We'd want to see if we can reduce the 15-20% worst-case penalty in followup
work.

RELNOTES: Adds --experimental_existing_rules_immutable_view flag to make the
native.existing_rule and native.existing_rules functions more efficient by
returning immutable, lightweight dict-like view objects instead of mutable
dicts.
PiperOrigin-RevId: 397805096
diff --git a/src/main/java/com/google/devtools/build/lib/packages/Package.java b/src/main/java/com/google/devtools/build/lib/packages/Package.java
index 6f919b5..77b7298 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/Package.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/Package.java
@@ -19,7 +19,6 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.BiMap;
-import com.google.common.collect.HashBiMap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -978,7 +977,10 @@
     private License defaultLicense = License.NO_LICENSE;
     private Set<License.DistributionType> defaultDistributionSet = License.DEFAULT_DISTRIB;
 
-    private BiMap<String, Target> targets = HashBiMap.create();
+    // All targets added to the package. We use SnapshottableBiMap to help track insertion order of
+    // Rule targets, for use by native.existing_rules().
+    private BiMap<String, Target> targets =
+        new SnapshottableBiMap<>(target -> target instanceof Rule);
     private final Map<Label, EnvironmentGroup> environmentGroups = new HashMap<>();
 
     /**
@@ -1479,14 +1481,22 @@
     }
 
     /**
-     * Removes a target from the {@link Package} under construction. Intended to be used only by
-     * {@link com.google.devtools.build.lib.skyframe.PackageFunction} to remove targets whose labels
-     * cross subpackage boundaries.
+     * Replaces a target in the {@link Package} under construction with a new target with the same
+     * name and belonging to the same package.
+     *
+     * <p>A hack needed for {@link WorkspaceFactoryHelper}.
      */
-    void removeTarget(Target target) {
-      if (target.getPackage() == pkg) {
-        this.targets.remove(target.getName());
-      }
+    void replaceTarget(Target newTarget) {
+      Preconditions.checkArgument(
+          targets.containsKey(newTarget.getName()),
+          "No existing target with name '%s' in the targets map",
+          newTarget.getName());
+      Preconditions.checkArgument(
+          newTarget.getPackage() == pkg, // pointer comparison since we're constructing `pkg`
+          "Replacement target belongs to package '%s', expected '%s'",
+          newTarget.getPackage(),
+          pkg);
+      targets.put(newTarget.getName(), newTarget);
     }
 
     public Set<Target> getTargets() {
@@ -1494,6 +1504,24 @@
     }
 
     /**
+     * Returns a lightweight snapshot view of the names of all rule targets belonging to this
+     * package at the time of this call.
+     *
+     * @throws IllegalStateException if this method is called after {@link beforeBuild} has been
+     *     called.
+     */
+    Map<String, Rule> getRulesSnapshotView() {
+      if (targets instanceof SnapshottableBiMap<?, ?>) {
+        return Maps.transformValues(
+            ((SnapshottableBiMap<String, Target>) targets).getTrackedSnapshot(),
+            target -> (Rule) target);
+      } else {
+        throw new IllegalStateException(
+            "getRulesSnapshotView() cannot be used after beforeBuild() has been called");
+      }
+    }
+
+    /**
      * Returns an (immutable, unordered) view of all the targets belonging to this package which are
      * instances of the specified class.
      */
@@ -1556,8 +1584,10 @@
       if (!((InputFile) cacheInstance).isVisibilitySpecified()
           || cacheInstance.getVisibility() != visibility
           || !Objects.equals(cacheInstance.getLicense(), license)) {
-        targets.put(filename, new InputFile(
-            pkg, cacheInstance.getLabel(), cacheInstance.getLocation(), visibility, license));
+        targets.put(
+            filename,
+            new InputFile(
+                pkg, cacheInstance.getLabel(), cacheInstance.getLocation(), visibility, license));
       }
     }
 
@@ -1718,6 +1748,17 @@
             getPackageIdentifier(), ioExceptionMessage, ioException, ioExceptionDetailedExitCode);
       }
 
+      // SnapshottableBiMap does not allow removing targets (in order to efficiently track rule
+      // insertion order). However, we *do* need to support removal of targets in
+      // PackageFunction.handleLabelsCrossingSubpackagesAndPropagateInconsistentFilesystemExceptions
+      // which is called *between* calls to beforeBuild and finishBuild. We thus repoint the targets
+      // map to the SnapshottableBiMap's underlying bimap and thus stop tracking insertion order.
+      // After this point, snapshots of targets should no longer be used, and any further
+      // getRulesSnapshotView calls will throw.
+      if (targets instanceof SnapshottableBiMap<?, ?>) {
+        targets = ((SnapshottableBiMap<String, Target>) targets).getUnderlyingBiMap();
+      }
+
       // We create the original BUILD InputFile when the package filename is set; however, the
       // visibility may be overridden with an exports_files directive, so we need to obtain the
       // current instance here.
diff --git a/src/main/java/com/google/devtools/build/lib/packages/SnapshottableBiMap.java b/src/main/java/com/google/devtools/build/lib/packages/SnapshottableBiMap.java
new file mode 100644
index 0000000..22f4027
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/packages/SnapshottableBiMap.java
@@ -0,0 +1,499 @@
+// Copyright 2021 The Bazel 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.
+
+package com.google.devtools.build.lib.packages;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+import com.google.common.collect.Maps;
+import com.google.common.collect.UnmodifiableIterator;
+import java.util.AbstractMap;
+import java.util.AbstractSet;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Set;
+import java.util.function.Predicate;
+
+/**
+ * A bimap with the following features and restrictions:
+ *
+ * <ul>
+ *   <li>it (lazily) tracks the order in which keys were inserted;
+ *   <li>... but only for entries whose values satisfy a predicate;
+ *   <li>it's append-only, i.e. it supports addition of new key-value pairs, or replacement of the
+ *       value of an existing key, but not deletion of key-value pairs;
+ *   <li>... with the restriction that replacement is not allowed to make a previously tracked entry
+ *       become untracked.
+ * </ul>
+ *
+ * <p>Tracking the insertion order and prohibiting key deletion allows this bimap to provide a
+ * lightweight snapshot view for iterating (in key insertion order) over entries which existed at a
+ * given point in time.
+ *
+ * <p>Intended to be used by {@code native.existing_rules} in Starlark, which needs to be able to
+ * iterate, at some later point in time, over the rules which existed in a {@link Package.Builder}
+ * at the time of the {@code existing_rules} call. We do not want to track insertion orders of
+ * numerous non-rule targets (e.g. files) - hence, filtering by a predicate. And in the common case
+ * where {@code existing_rules} is never called in a package, we want to avoid the overhead of
+ * keeping track of insertion orders - hence, laziness.
+ *
+ * <p>In packages with a large number of targets, the use of lightweight snapshots instead of
+ * copying results in a noticeable improvement in loading times, e.g. 2.2 times faster loading for a
+ * package with 4400 targets and 300 {@code native.existing_rules} calls.
+ */
+final class SnapshottableBiMap<K, V> implements BiMap<K, V> {
+  private final BiMap<K, V> contents = HashBiMap.create();
+  private final Predicate<V> track;
+
+  // trackedKeys and trackedKeyOrders are initialized lazily by ensureOrderTracking(). In the case
+  // where the order-tracking map represents a package builder's targets, ensureOrderTracking() is
+  // intended to be triggered only by a call to {@code native.existing_rules} in Starlark.
+  //
+  // Holds all keys being tracked, in their relative insertion order.
+  private ArrayList<K> trackedKeys;
+  // Maps all keys being tracked to their index in trackedKeys.
+  private Map<K, Integer> trackedKeyOrders;
+
+  public SnapshottableBiMap(Predicate<V> track) {
+    this.track = track;
+  }
+
+  /**
+   * Returns the underlying contents bimap.
+   *
+   * <p>Mutating the underlying bimap will violate the guarantees of this class and possibly cause
+   * inconsistent behavior in snapshot views. Therefore, the recommended usage pattern is to replace
+   * any references to the {@code SnapshottableBiMap} with the underlying map, and ensure that any
+   * snapshots of the map are no longer in use at that point.
+   *
+   * <p>An optimization hack intended only for use from {@link Package.Builder#beforeBuild}.
+   */
+  BiMap<K, V> getUnderlyingBiMap() {
+    return contents;
+  }
+
+  @Override
+  public int size() {
+    return contents.size();
+  }
+
+  private int sizeTracked() {
+    ensureOrderTracking();
+    return trackedKeyOrders.size();
+  }
+
+  @Override
+  public boolean isEmpty() {
+    return contents.isEmpty();
+  }
+
+  @Override
+  public boolean containsKey(Object key) {
+    return contents.containsKey(key);
+  }
+
+  @Override
+  public boolean containsValue(Object value) {
+    return contents.containsValue(value);
+  }
+
+  @Override
+  public V get(Object key) {
+    return contents.get(key);
+  }
+
+  /**
+   * Returns the insertion order of the specified key (relative to other tracked keys), or -1 if the
+   * key was never inserted into the map or corresponds to a key-value pair whose insertion order we
+   * do not track. Replacing a key's value does not change this order if tracking has already begun.
+   */
+  private int getTrackedKeyOrder(Object key) {
+    ensureOrderTracking();
+    Integer order = trackedKeyOrders.get(key);
+    return order == null ? -1 : order;
+  }
+
+  /**
+   * Returns the tracked key with the specified insertion order (as determined by {@link
+   * #getTrackedKeyOrder}).
+   *
+   * @throws IndexOutOfBoundsException if the specified insertion order is out of bounds
+   */
+  private K getTrackedKey(int order) {
+    ensureOrderTracking();
+    return trackedKeys.get(order);
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Note that once key insertion order tracking has started, overriding a key with a different
+   * value will not change the key's insertion order.
+   *
+   * @throws IllegalArgumentException if attempting to replace a key-value pair whose insertion
+   *     order was tracked with a key-value pair whose insertion order is not tracked, or if the
+   *     given value is already bound to a different key in this map.
+   */
+  @Override
+  public V put(K key, V value) {
+    if (startedOrderTracking()) {
+      boolean oldWasTracked = getTrackedKeyOrder(key) >= 0;
+      boolean newIsTracked = track.test(value);
+      if (oldWasTracked) {
+        Preconditions.checkArgument(
+            newIsTracked,
+            "Cannot replace a key-value pair which is tracked with a key-value pair which is"
+                + " not tracked");
+      } else {
+        if (newIsTracked) {
+          recordKeyOrder(key);
+        }
+      }
+    }
+    return contents.put(key, value);
+  }
+
+  /**
+   * @deprecated Not supported, since it's morally equivalent to preceding a {@link #put} call with
+   *     a silent {@code this.values().remove(value)}.
+   * @throws UnsupportedOperationException always.
+   */
+  @Deprecated
+  @Override
+  public V forcePut(K key, V value) {
+    throw new UnsupportedOperationException("Append-only data structure");
+  }
+
+  /**
+   * @deprecated Not supported.
+   * @throws UnsupportedOperationException always.
+   */
+  @Deprecated
+  @Override
+  public V remove(Object key) {
+    throw new UnsupportedOperationException("Append-only data structure");
+  }
+
+  @Override
+  public void putAll(Map<? extends K, ? extends V> map) {
+    for (Map.Entry<? extends K, ? extends V> entry : map.entrySet()) {
+      put(entry.getKey(), entry.getValue());
+    }
+  }
+
+  /**
+   * @deprecated Not supported.
+   * @throws UnsupportedOperationException always.
+   */
+  @Deprecated
+  @Override
+  public void clear() {
+    throw new UnsupportedOperationException("Append-only data structure");
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Removing a key from the set does not change the key's order if it was tracked prior to
+   * removal. Removal is supported only for consistency with {@link values}.
+   */
+  @Override
+  public Set<K> keySet() {
+    return Collections.unmodifiableSet(contents.keySet());
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Removing a value from the set does not change the key's order if it was tracked prior to
+   * removal. Ideally, we would not want to support removal, but it is required for {@link
+   * PackageFunction#handleLabelsCrossingSubpackagesAndPropagateInconsistentFilesystemExceptions}.
+   */
+  @Override
+  public Set<V> values() {
+    return Collections.unmodifiableSet(contents.values());
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Removing an entry from the set does not change the key's order if it was tracked prior to
+   * removal. Removal is supported only for consistency with {@link values}.
+   */
+  @Override
+  public Set<Map.Entry<K, V>> entrySet() {
+    return Collections.unmodifiableSet(contents.entrySet());
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>The returned map is unmodifiable (all modifications will throw an {@link
+   * UnsupportedOperationException}.
+   */
+  @Override
+  public BiMap<V, K> inverse() {
+    return Maps.unmodifiableBiMap(contents.inverse());
+  }
+
+  private boolean startedOrderTracking() {
+    Preconditions.checkState((trackedKeys == null) == (trackedKeyOrders == null));
+    return trackedKeys != null;
+  }
+
+  private void ensureOrderTracking() {
+    if (!startedOrderTracking()) {
+      trackedKeys = new ArrayList<>();
+      trackedKeyOrders = new HashMap<>();
+
+      contents.forEach(
+          (key, value) -> {
+            if (track.test(value)) {
+              recordKeyOrder(key);
+            }
+          });
+    }
+  }
+
+  private void recordKeyOrder(K key) {
+    int order = trackedKeys.size();
+    trackedKeys.add(key);
+    trackedKeyOrders.put(key, order);
+  }
+
+  /**
+   * Returns a lightweight snapshot view of the tracked entries existing in the bimap at the time
+   * this method is called.
+   *
+   * <p>Most method calls on the view returned by this method will start insertion order tracking if
+   * it has not been started already. In particular, that implies that after this method had been
+   * called, a value whose insertion order was tracked may no longer be replaceable with a value
+   * whose insertion order is not tracked. See {@link #put} for details.
+   */
+  public Map<K, V> getTrackedSnapshot() {
+    return new TrackedSnapshot<>(this);
+  }
+
+  /**
+   * A view of a {@link SnapshottableBiMap}'s contents existing at a certain point in time.
+   *
+   * <p>Iterators over the view's {@link #keySet}, {@link #entrySet}, or {@link #values} iterate in
+   * key insertion order.
+   */
+  static final class TrackedSnapshot<K, V> extends AbstractMap<K, V> {
+    private final SnapshottableBiMap<K, V> underlying;
+    // The number of initial elements from `underlying`'s `trackedKeys` list that should be
+    // considered to be present in this view. Note that we don't snapshot values, so we'll use
+    // whatever the most recent value in `underlying` is even if it changed after this snapshot
+    // was created.
+    private final int sizeTracked;
+
+    private TrackedSnapshot(SnapshottableBiMap<K, V> underlying) {
+      this.underlying = underlying;
+      this.sizeTracked = underlying.sizeTracked();
+    }
+
+    @Override
+    public boolean containsKey(Object key) {
+      int order = underlying.getTrackedKeyOrder(key);
+      return order >= 0 && order < sizeTracked;
+    }
+
+    @Override
+    public boolean containsValue(Object value) {
+      Object key = underlying.inverse().get(value);
+      if (key != null) {
+        int order = underlying.getTrackedKeyOrder(key);
+        return order >= 0 && order < sizeTracked;
+      } else {
+        return false;
+      }
+    }
+
+    @Override
+    public V get(Object key) {
+      if (containsKey(key)) {
+        return underlying.get(key);
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * @deprecated Unsupported operation.
+     * @throws UnsupportedOperationException always.
+     */
+    @Deprecated
+    @Override
+    public V put(K key, V value) {
+      throw new UnsupportedOperationException("Read-only snapshot");
+    }
+
+    /**
+     * @deprecated Unsupported operation.
+     * @throws UnsupportedOperationException always.
+     */
+    @Deprecated
+    @Override
+    public V remove(Object key) {
+      throw new UnsupportedOperationException("Read-only snapshot");
+    }
+
+    /**
+     * @deprecated Unsupported operation.
+     * @throws UnsupportedOperationException always.
+     */
+    @Deprecated
+    @Override
+    public void putAll(Map<? extends K, ? extends V> m) {
+      throw new UnsupportedOperationException("Read-only snapshot");
+    }
+
+    /**
+     * @deprecated Unsupported operation.
+     * @throws UnsupportedOperationException always.
+     */
+    @Deprecated
+    @Override
+    public void clear() {
+      throw new UnsupportedOperationException("Read-only snapshot");
+    }
+
+    @Override
+    public Set<Map.Entry<K, V>> entrySet() {
+      return new UnmodifiableSet<Map.Entry<K, V>>() {
+        @Override
+        public int size() {
+          return sizeTracked;
+        }
+
+        @Override
+        public boolean isEmpty() {
+          return sizeTracked == 0;
+        }
+
+        @Override
+        public boolean contains(Object object) {
+          if (!(object instanceof Map.Entry<?, ?>)) {
+            return false;
+          }
+          Map.Entry<?, ?> entry = (Map.Entry<?, ?>) object;
+          return TrackedSnapshot.this.containsKey(entry.getKey())
+              && TrackedSnapshot.this.containsValue(entry.getValue());
+        }
+
+        @Override
+        public Iterator<Map.Entry<K, V>> iterator() {
+          return new UnmodifiableIterator<Map.Entry<K, V>>() {
+            private int nextOrder = 0;
+
+            @Override
+            public boolean hasNext() {
+              return nextOrder < TrackedSnapshot.this.sizeTracked;
+            }
+
+            @Override
+            public Map.Entry<K, V> next() {
+              if (!hasNext()) {
+                throw new NoSuchElementException();
+              }
+              K key = TrackedSnapshot.this.underlying.getTrackedKey(nextOrder);
+              V value = TrackedSnapshot.this.underlying.get(key);
+              nextOrder++;
+              return new AbstractMap.SimpleEntry<>(key, value);
+            }
+          };
+        }
+      };
+    }
+
+    private abstract static class UnmodifiableSet<E> extends AbstractSet<E> {
+      /**
+       * @deprecated Unsupported operation.
+       * @throws UnsupportedOperationException always.
+       */
+      @Deprecated
+      @Override
+      public boolean add(E entry) {
+        throw new UnsupportedOperationException();
+      }
+
+      /**
+       * @deprecated Unsupported operation.
+       * @throws UnsupportedOperationException always.
+       */
+      @Deprecated
+      @Override
+      public boolean remove(Object o) {
+        throw new UnsupportedOperationException();
+      }
+
+      /**
+       * @deprecated Not implemented due to lack of need.
+       * @throws UnsupportedOperationException always.
+       */
+      @Deprecated
+      @Override
+      public boolean containsAll(Collection<?> c) {
+        throw new UnsupportedOperationException();
+      }
+
+      /**
+       * @deprecated Unsupported operation.
+       * @throws UnsupportedOperationException always.
+       */
+      @Deprecated
+      @Override
+      public boolean addAll(Collection<? extends E> c) {
+        throw new UnsupportedOperationException();
+      }
+
+      /**
+       * @deprecated Unsupported operation.
+       * @throws UnsupportedOperationException always.
+       */
+      @Deprecated
+      @Override
+      public boolean retainAll(Collection<?> c) {
+        throw new UnsupportedOperationException();
+      }
+
+      /**
+       * @deprecated Unsupported operation.
+       * @throws UnsupportedOperationException always.
+       */
+      @Deprecated
+      @Override
+      public boolean removeAll(Collection<?> c) {
+        throw new UnsupportedOperationException();
+      }
+
+      /**
+       * @deprecated Unsupported operation.
+       * @throws UnsupportedOperationException always.
+       */
+      @Deprecated
+      @Override
+      public void clear() {
+        throw new UnsupportedOperationException();
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/packages/StarlarkNativeModule.java b/src/main/java/com/google/devtools/build/lib/packages/StarlarkNativeModule.java
index 438b6b2..709bf0b 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/StarlarkNativeModule.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/StarlarkNativeModule.java
@@ -15,10 +15,13 @@
 package com.google.devtools.build.lib.packages;
 
 import static com.google.devtools.build.lib.packages.PackageFactory.getContext;
+import static java.util.Comparator.naturalOrder;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterators;
 import com.google.common.flogger.GoogleLogger;
 import com.google.devtools.build.lib.cmdline.Label;
 import com.google.devtools.build.lib.cmdline.LabelSyntaxException;
@@ -36,18 +39,24 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Comparator;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import javax.annotation.Nullable;
+import net.starlark.java.annot.Param;
+import net.starlark.java.annot.StarlarkMethod;
 import net.starlark.java.eval.Dict;
 import net.starlark.java.eval.EvalException;
 import net.starlark.java.eval.Mutability;
 import net.starlark.java.eval.NoneType;
+import net.starlark.java.eval.Printer;
 import net.starlark.java.eval.Sequence;
 import net.starlark.java.eval.Starlark;
+import net.starlark.java.eval.StarlarkIndexable;
 import net.starlark.java.eval.StarlarkInt;
+import net.starlark.java.eval.StarlarkIterable;
 import net.starlark.java.eval.StarlarkList;
+import net.starlark.java.eval.StarlarkSemantics;
 import net.starlark.java.eval.StarlarkThread;
 import net.starlark.java.eval.StarlarkValue;
 import net.starlark.java.eval.Tuple;
@@ -140,17 +149,234 @@
       }
       result.add(match);
     }
-    result.sort(Comparator.naturalOrder());
+    result.sort(naturalOrder());
 
     return StarlarkList.copyOf(thread.mutability(), result);
   }
 
+  // TODO(https://github.com/bazelbuild/bazel/issues/13605): implement StarlarkMapping (after we've
+  // added such an interface) to allow `dict(native.existing_rule(x))`.
+  private static interface DictLikeView extends StarlarkIndexable, StarlarkIterable<String> {
+    @Override
+    public default boolean isImmutable() {
+      return true;
+    }
+
+    @StarlarkMethod(
+        name = "get",
+        doc = "Behaves the same as <a href=\"dict.html#get\"><code>dict.get</code></a>.",
+        parameters = {
+          @Param(name = "key", doc = "The key to look for."),
+          @Param(
+              name = "default",
+              defaultValue = "None",
+              named = true,
+              doc = "The default value to use (instead of None) if the key is not found.")
+        },
+        allowReturnNones = true)
+    @Nullable // Java callers expect a null return when defaultValue is null
+    public Object get(Object key, @Nullable Object defaultValue) throws EvalException;
+
+    @StarlarkMethod(
+        name = "keys",
+        doc =
+            "Behaves like <a href=\"dict.html#keys\"><code>dict.keys</code></a>, but the returned"
+                + " value is an immutable sequence.")
+    public default StarlarkIterable<String> keys() {
+      // TODO(https://github.com/bazelbuild/starlark/issues/203): return a sequence view which
+      // supports efficient membership lookup (`"foo" in existing_rule("bar").keys()`), and
+      // materializes into a list (to allow len() or lookup by integer index) only if needed. Note
+      // that materialization into a list would need to be thread-safe (assuming it's possible for
+      // the sequence view to be used from multiple starlark threads). For now, we return an
+      // immutable list, so that migration to a sequence view is less likely to cause breakage.
+      return StarlarkList.immutableCopyOf(this);
+    }
+
+    @StarlarkMethod(
+        name = "values",
+        doc =
+            "Behaves like <a href=\"dict.html#values\"><code>dict.values</code></a>, but the"
+                + " returned value is an immutable sequence.")
+    public default StarlarkIterable<Object> values() throws EvalException {
+      // TODO(https://github.com/bazelbuild/starlark/issues/203): return a sequence view; see keys()
+      // for implementation concerns.
+      ArrayList<Object> valueList = new ArrayList<>();
+      for (String key : this) {
+        valueList.add(Preconditions.checkNotNull(get(key, null)));
+      }
+      return StarlarkList.immutableCopyOf(valueList);
+    }
+
+    @StarlarkMethod(
+        name = "items",
+        doc =
+            "Behaves like <a href=\"dict.html#items\"><code>dict.items</code></a>, but the returned"
+                + " value is an immutable sequence.")
+    public default StarlarkIterable<Tuple> items() throws EvalException {
+      // TODO(https://github.com/bazelbuild/starlark/issues/203): return a sequence view; see keys()
+      // for implementation concerns.
+      ArrayList<Tuple> itemsList = new ArrayList<>();
+      for (String key : this) {
+        itemsList.add(Tuple.pair(key, Preconditions.checkNotNull(get(key, null))));
+      }
+      return StarlarkList.immutableCopyOf(itemsList);
+    }
+  }
+
+  private static final class ExistingRuleView implements DictLikeView {
+    private final Rule rule;
+
+    ExistingRuleView(Rule rule) {
+      this.rule = rule;
+    }
+
+    @Override
+    public void repr(Printer printer) {
+      printer.append("<native.ExistingRuleView for target '").append(rule.getName()).append("'>");
+    }
+
+    @Override
+    @Nullable // Java callers expect a null return when defaultValue is null
+    public Object get(Object key, @Nullable Object defaultValue) throws EvalException {
+      if (!(key instanceof String)) {
+        return defaultValue;
+      }
+      String attributeName = (String) key;
+      switch (attributeName) {
+        case "name":
+          return rule.getName();
+        case "kind":
+          return rule.getRuleClass();
+        default:
+          if (!isPotentiallyExportableAttribute(rule.getRuleClassObject(), attributeName)) {
+            return defaultValue;
+          }
+          Object v =
+              starlarkifyValue(
+                  null /* immutable */, rule.getAttr(attributeName), rule.getPackage());
+          if (v != null) {
+            return v;
+          }
+      }
+      return defaultValue;
+    }
+
+    @Override
+    public Iterator<String> iterator() {
+      return Iterators.concat(
+          ImmutableList.of("name", "kind").iterator(),
+          rule.getAttributes().stream()
+              .map(Attribute::getName)
+              .filter(
+                  attributeName -> {
+                    switch (attributeName) {
+                      case "name":
+                      case "kind":
+                        // handled specially
+                        return false;
+                      default:
+                        return isPotentiallyExportableAttribute(
+                                rule.getRuleClassObject(), attributeName)
+                            && isPotentiallyStarlarkifiableValue(rule.getAttr(attributeName));
+                    }
+                  })
+              .iterator());
+    }
+
+    @Override
+    public Object getIndex(StarlarkSemantics semantics, Object key) throws EvalException {
+      Object val = get(key, null);
+      if (val != null) {
+        return val;
+      }
+      throw Starlark.errorf("key %s not found in view", Starlark.repr(key));
+    }
+
+    @Override
+    public boolean containsKey(StarlarkSemantics semantics, Object key) {
+      if (!(key instanceof String)) {
+        return false;
+      }
+      String attributeName = (String) key;
+      switch (attributeName) {
+        case "name":
+        case "kind":
+          return true;
+        default:
+          return isPotentiallyExportableAttribute(rule.getRuleClassObject(), attributeName)
+              && isPotentiallyStarlarkifiableValue(rule.getAttr(attributeName));
+      }
+    }
+  }
+
   @Override
   public Object existingRule(String name, StarlarkThread thread) throws EvalException {
     BazelStarlarkContext.from(thread).checkLoadingOrWorkspacePhase("native.existing_rule");
     PackageContext context = getContext(thread);
     Target target = context.pkgBuilder.getTarget(name);
-    return target instanceof Rule ? getRuleDict((Rule) target, thread.mutability()) : Starlark.NONE;
+    if (target instanceof Rule /* `instanceof` also verifies that target != null */) {
+      Rule rule = (Rule) target;
+      if (thread
+          .getSemantics()
+          .getBool(BuildLanguageOptions.EXPERIMENTAL_EXISTING_RULES_IMMUTABLE_VIEW)) {
+        return new ExistingRuleView(rule);
+      } else {
+        return getRuleDict(rule, thread.mutability());
+      }
+    } else {
+      return Starlark.NONE;
+    }
+  }
+
+  private static final class ExistingRulesView implements DictLikeView {
+    // We take a lightweight snapshot of the rules existing in a Package.Builder to avoid exposing
+    // any rules added to Package.Builder after the existing_rules() call which created this view.
+    private final Map<String, Rule> rulesSnapshotView;
+
+    ExistingRulesView(Map<String, Rule> rulesSnapshotView) {
+      this.rulesSnapshotView = rulesSnapshotView;
+    }
+
+    @Override
+    public void repr(Printer printer) {
+      printer.append("<native.ExistingRulesView object>");
+    }
+
+    @Override
+    @Nullable // Java callers expect a null return when defaultValue is null
+    public Object get(Object key, @Nullable Object defaultValue) {
+      if (!(key instanceof String)) {
+        return defaultValue;
+      }
+      Rule rule = rulesSnapshotView.get(key);
+      if (rule != null) {
+        return new ExistingRuleView(rule);
+      } else {
+        return defaultValue;
+      }
+    }
+
+    @Override
+    public Iterator<String> iterator() {
+      return rulesSnapshotView.keySet().iterator();
+    }
+
+    @Override
+    public Object getIndex(StarlarkSemantics semantics, Object key) throws EvalException {
+      Object val = get(key, null);
+      if (val != null) {
+        return val;
+      }
+      throw Starlark.errorf("key %s not found in view", Starlark.repr(key));
+    }
+
+    @Override
+    public boolean containsKey(StarlarkSemantics semantics, Object key) {
+      if (!(key instanceof String)) {
+        return false;
+      }
+      return rulesSnapshotView.containsKey(key);
+    }
   }
 
   /*
@@ -158,27 +384,29 @@
     For now, we ignore this, since users can implement it in Starlark.
   */
   @Override
-  public Dict<String, Dict<String, Object>> existingRules(StarlarkThread thread)
-      throws EvalException {
+  public Object existingRules(StarlarkThread thread) throws EvalException {
     BazelStarlarkContext.from(thread).checkLoadingOrWorkspacePhase("native.existing_rules");
     PackageContext context = getContext(thread);
-    Collection<Target> targets = context.pkgBuilder.getTargets();
-    Mutability mu = thread.mutability();
-    Dict.Builder<String, Dict<String, Object>> rules = Dict.builder();
-    for (Target t : targets) {
-      if (t instanceof Rule) {
-        rules.put(t.getName(), getRuleDict((Rule) t, mu));
+    if (thread
+        .getSemantics()
+        .getBool(BuildLanguageOptions.EXPERIMENTAL_EXISTING_RULES_IMMUTABLE_VIEW)) {
+      return new ExistingRulesView(context.pkgBuilder.getRulesSnapshotView());
+    } else {
+      Collection<Target> targets = context.pkgBuilder.getTargets();
+      Mutability mu = thread.mutability();
+      Dict.Builder<String, Dict<String, Object>> rules = Dict.builder();
+      for (Target t : targets) {
+        if (t instanceof Rule) {
+          rules.put(t.getName(), getRuleDict((Rule) t, mu));
+        }
       }
+      return rules.build(mu);
     }
-    return rules.build(mu);
   }
 
   @Override
   public NoneType packageGroup(
-      String name,
-      Sequence<?> packagesO,
-      Sequence<?> includesO,
-      StarlarkThread thread)
+      String name, Sequence<?> packagesO, Sequence<?> includesO, StarlarkThread thread)
       throws EvalException {
     BazelStarlarkContext.from(thread).checkLoadingPhase("native.package_group");
     PackageContext context = getContext(thread);
@@ -290,13 +518,7 @@
     Dict.Builder<String, Object> values = Dict.builder();
 
     for (Attribute attr : rule.getAttributes()) {
-      if (!Character.isAlphabetic(attr.getName().charAt(0))) {
-        continue;
-      }
-
-      if (attr.getName().equals("distribs")) {
-        // attribute distribs: cannot represent type class java.util.Collections$SingletonSet
-        // in Starlark: [INTERNAL].
+      if (!isPotentiallyExportableAttribute(rule.getRuleClassObject(), attr.getName())) {
         continue;
       }
 
@@ -319,22 +541,81 @@
   }
 
   /**
+   * Returns true if the given attribute of a rule class is generally allowed to be exposed via
+   * {@code native.existing_rule()} and {@code native.existing_rules()}.
+   *
+   * <p>This method makes no attempt to validate that the attribute exists in the rule class.
+   *
+   * <p>Even if this method returns true, the attribute may still be suppressed if it has a
+   * prohibited value (e.g. is of a bad type, or is a select() that cannot be processed).
+   */
+  private static boolean isPotentiallyExportableAttribute(
+      RuleClass ruleClass, String attributeName) {
+    if (attributeName.length() == 0 || !Character.isAlphabetic(attributeName.charAt(0))) {
+      // Do not expose hidden or implicit attributes.
+      return false;
+    }
+    if (attributeName.equals("distribs")) {
+      Attribute attr = ruleClass.getAttributeByName(attributeName);
+      if (attr != null && attr.getType() == BuildType.DISTRIBUTIONS) {
+        // "distribs" attribute (a Set<License.DistributionType> value) is not a StarlarkValue. Note
+        // that we cannot check for a Set<License.DistributionType> directly because generic type
+        // info is erased at runime.
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Returns true if the given value is generally allowed to be exposed via {@code
+   * native.existing_rule()} and or {@code native.existing_rules()}. Returns false for null.
+   *
+   * <p>Even if this method returns true, the value may still be suppressed if it is a select() that
+   * cannot be processed.
+   */
+  private static boolean isPotentiallyStarlarkifiableValue(@Nullable Object val) {
+    if (val == null) {
+      return false;
+    }
+    if (val.getClass().isAnonymousClass()) {
+      // Computed defaults. They will be represented as
+      // "deprecation": com.google.devtools.build.lib.analysis.BaseRuleClasses$2@6960884a,
+      // Filter them until we invent something more clever.
+      return false;
+    }
+
+    if (val instanceof License) {
+      // License is deprecated as a Starlark type, so omit this type from Starlark values
+      // to avoid exposing these objects, even though they are technically StarlarkValue.
+      return false;
+    }
+
+    return true;
+  }
+
+  /**
    * Converts a target attribute value to a Starlark value for return in {@code
    * native.existing_rule()} or {@code native.existing_rules()}.
    *
    * <p>Any dict values in the result have mutability {@code mu}.
    *
+   * <p>Any label values in the result which are inside {@code pkg} (the current package) are
+   * rewritten using ":foo" shorthand.
+   *
    * @return the value, or null if we don't want to export it to the user.
    * @throws NotRepresentableException if an unknown type is encountered.
    */
+  // TODO(https://github.com/bazelbuild/bazel/issues/13829): don't throw NotRepresentableException;
+  // perhaps change to an unchecked exception instead?
   @Nullable
   private static Object starlarkifyValue(Mutability mu, Object val, Package pkg)
       throws NotRepresentableException {
     // easy cases
-    if (val == null
-        || val instanceof Boolean
-        || val instanceof String
-        || val instanceof StarlarkInt) {
+    if (!isPotentiallyStarlarkifiableValue(val)) {
+      return null;
+    }
+    if (val instanceof Boolean || val instanceof String || val instanceof StarlarkInt) {
       return val;
     }
 
@@ -352,6 +633,8 @@
     if (val instanceof Label) {
       Label l = (Label) val;
       if (l.getPackageName().equals(pkg.getName())) {
+        // TODO(https://github.com/bazelbuild/bazel/issues/13828): do not ignore the repo component
+        // of the label.
         return ":" + l.getName();
       }
       return l.getCanonicalForm();
@@ -385,19 +668,6 @@
       return m.build(mu);
     }
 
-    if (val.getClass().isAnonymousClass()) {
-      // Computed defaults. They will be represented as
-      // "deprecation": com.google.devtools.build.lib.analysis.BaseRuleClasses$2@6960884a,
-      // Filter them until we invent something more clever.
-      return null;
-    }
-
-    if (val instanceof License) {
-      // License is deprecated as a Starlark type, so omit this type from Starlark values
-      // to avoid exposing these objects, even though they are technically StarlarkValue.
-      return null;
-    }
-
     if (val instanceof BuildType.SelectorList) {
       List<Object> selectors = new ArrayList<>();
       for (BuildType.Selector<?> selector : ((BuildType.SelectorList<?>) val).getSelectors()) {
diff --git a/src/main/java/com/google/devtools/build/lib/packages/WorkspaceFactoryHelper.java b/src/main/java/com/google/devtools/build/lib/packages/WorkspaceFactoryHelper.java
index a0eaad8..06c1180 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/WorkspaceFactoryHelper.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/WorkspaceFactoryHelper.java
@@ -152,10 +152,9 @@
       if (old instanceof Rule) {
         Verify.verify(((Rule) old).getOutputFiles().isEmpty());
       }
-
-      pkg.removeTarget(rule);
+      pkg.replaceTarget(rule);
+    } else {
+      pkg.addRule(rule);
     }
-
-    pkg.addRule(rule);
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/packages/semantics/BuildLanguageOptions.java b/src/main/java/com/google/devtools/build/lib/packages/semantics/BuildLanguageOptions.java
index b5070bb..9d86319 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/semantics/BuildLanguageOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/semantics/BuildLanguageOptions.java
@@ -156,6 +156,17 @@
   public boolean incompatibleEnableExportsProvider;
 
   @Option(
+      name = "experimental_existing_rules_immutable_view",
+      defaultValue = "false",
+      documentationCategory = OptionDocumentationCategory.STARLARK_SEMANTICS,
+      effectTags = {OptionEffectTag.BUILD_FILE_SEMANTICS, OptionEffectTag.LOADING_AND_ANALYSIS},
+      metadataTags = {OptionMetadataTag.EXPERIMENTAL},
+      help =
+          "If set to true, native.existing_rule and native.existing_rules return lightweight"
+              + " immutable view objects instead of mutable dicts.")
+  public boolean experimentalExistingRulesImmutableView;
+
+  @Option(
       name = "experimental_google_legacy_api",
       defaultValue = "false",
       documentationCategory = OptionDocumentationCategory.STARLARK_SEMANTICS,
@@ -592,6 +603,8 @@
             .setBool(
                 EXPERIMENTAL_ENABLE_ANDROID_MIGRATION_APIS, experimentalEnableAndroidMigrationApis)
             .setBool(INCOMPATIBLE_ENABLE_EXPORTS_PROVIDER, incompatibleEnableExportsProvider)
+            .setBool(
+                EXPERIMENTAL_EXISTING_RULES_IMMUTABLE_VIEW, experimentalExistingRulesImmutableView)
             .setBool(EXPERIMENTAL_GOOGLE_LEGACY_API, experimentalGoogleLegacyApi)
             .setBool(EXPERIMENTAL_NINJA_ACTIONS, experimentalNinjaActions)
             .setBool(EXPERIMENTAL_PLATFORMS_API, experimentalPlatformsApi)
@@ -666,6 +679,8 @@
       "-experimental_enable_android_migration_apis";
   public static final String INCOMPATIBLE_ENABLE_EXPORTS_PROVIDER =
       "-incompatible_enable_exports_provider";
+  public static final String EXPERIMENTAL_EXISTING_RULES_IMMUTABLE_VIEW =
+      "-experimental_existing_rules_immutable_view";
   public static final String EXPERIMENTAL_GOOGLE_LEGACY_API = "-experimental_google_legacy_api";
   public static final String EXPERIMENTAL_NINJA_ACTIONS = "-experimental_ninja_actions";
   public static final String EXPERIMENTAL_PLATFORMS_API = "-experimental_platforms_api";
diff --git a/src/main/java/com/google/devtools/build/lib/starlarkbuildapi/StarlarkNativeModuleApi.java b/src/main/java/com/google/devtools/build/lib/starlarkbuildapi/StarlarkNativeModuleApi.java
index a0d07a6..381c8e9 100644
--- a/src/main/java/com/google/devtools/build/lib/starlarkbuildapi/StarlarkNativeModuleApi.java
+++ b/src/main/java/com/google/devtools/build/lib/starlarkbuildapi/StarlarkNativeModuleApi.java
@@ -19,7 +19,6 @@
 import net.starlark.java.annot.ParamType;
 import net.starlark.java.annot.StarlarkBuiltin;
 import net.starlark.java.annot.StarlarkMethod;
-import net.starlark.java.eval.Dict;
 import net.starlark.java.eval.EvalException;
 import net.starlark.java.eval.NoneType;
 import net.starlark.java.eval.Sequence;
@@ -93,13 +92,20 @@
   @StarlarkMethod(
       name = "existing_rule",
       doc =
-          "Returns a new mutable dict that describes the attributes of a rule instantiated in this "
-              + "thread's package, or <code>None</code> if no rule instance of that name exists." //
-              + "<p>The dict contains an entry for each attribute, except private ones, whose"
-              + " names do not start with a letter. In addition, the dict contains entries for the"
-              + " rule instance's <code>name</code> and <code>kind</code> (for example,"
+          "Returns a new mutable dict that describes the attributes of a rule instantiated in this"
+              + " thread's package, or <code>None</code> if no rule instance of that name"
+              + " exists." //
+              + "<p>If the <code>--experimental_existing_rules_immutable_view</code> flag is set,"
+              + " instead returns an immutable dict-like object (i.e. supporting dict-like"
+              + " iteration, <code>len(x)</code>, <code>name in x</code>, <code>x[name]</code>,"
+              + " <code>x.get(name)</code>, <code>x.items()</code>, <code>x.keys()</code>, and"
+              + " <code>x.values()</code>) with the same content." //
+              + "<p>The result contains an entry for each attribute, with the exception of private"
+              + " ones (whose names do not start with a letter) and a few unrepresentable legacy"
+              + " attribute types. In addition, the dict contains entries for the rule instance's"
+              + " <code>name</code> and <code>kind</code> (for example,"
               + " <code>'cc_binary'</code>)." //
-              + "<p>The values of the dict represent attribute values as follows:" //
+              + "<p>The values of the result represent attribute values as follows:" //
               + "<ul><li>Attributes of type str, int, and bool are represented as is.</li>" //
               + "<li>Labels are converted to strings of the form <code>':foo'</code> for targets"
               + " in the same package or <code>'//pkg:name'</code> for targets in a different"
@@ -125,11 +131,18 @@
       doc =
           "Returns a new mutable dict describing the rules so far instantiated in this thread's"
               + " package. Each dict entry maps the name of the rule instance to the result that"
-              + " would be returned by <code>existing_rule(name)</code>.<p><i>Note: If possible,"
-              + " avoid using this function. It makes BUILD files brittle and order-dependent, and"
-              + " it may be expensive especially if called within a loop.</i>",
+              + " would be returned by <code>existing_rule(name)</code>." //
+              + "<p>If the <code>--experimental_existing_rules_immutable_view</code> flag is set,"
+              + " instead returns an immutable dict-like object (i.e. supporting dict-like"
+              + " iteration, <code>len(x)</code>, <code>name in x</code>, <code>x[name]</code>,"
+              + " <code>x.get(name)</code>, <code>x.items()</code>, <code>x.keys()</code>, and"
+              + " <code>x.values()</code>) with the same content." //
+              + "<p><i>Note: If possible, avoid using this function. It makes BUILD files brittle"
+              + " and order-dependent. Furthermore, unless the"
+              + " <code>--experimental_existing_rules_immutable_view</code> flag is set, this"
+              + " function may be very expensive, especially if called within a loop.</i>",
       useStarlarkThread = true)
-  Dict<String, Dict<String, Object>> existingRules(StarlarkThread thread) throws EvalException;
+  Object existingRules(StarlarkThread thread) throws EvalException;
 
   @StarlarkMethod(
       name = "package_group",
diff --git a/src/test/java/com/google/devtools/build/lib/packages/BUILD b/src/test/java/com/google/devtools/build/lib/packages/BUILD
index e4f2249..f256d90 100644
--- a/src/test/java/com/google/devtools/build/lib/packages/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/packages/BUILD
@@ -90,6 +90,7 @@
         "//src/test/java/com/google/devtools/build/lib/testutil",
         "//src/test/java/com/google/devtools/build/lib/testutil:JunitUtils",
         "//src/test/java/com/google/devtools/build/lib/testutil:TestUtils",
+        "//third_party:auto_value",
         "//third_party:guava",
         "//third_party:guava-testlib",
         "//third_party:jsr305",
diff --git a/src/test/java/com/google/devtools/build/lib/packages/NativeExistingRulesTest.java b/src/test/java/com/google/devtools/build/lib/packages/NativeExistingRulesTest.java
new file mode 100644
index 0000000..ceb3c6f
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/packages/NativeExistingRulesTest.java
@@ -0,0 +1,452 @@
+// Copyright 2021 The Bazel 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.
+
+package com.google.devtools.build.lib.packages;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.devtools.build.lib.analysis.ConfiguredRuleClassProvider;
+import com.google.devtools.build.lib.analysis.util.BuildViewTestCase;
+import com.google.devtools.build.lib.testutil.TestRuleClassProvider;
+import java.util.HashMap;
+import java.util.Map;
+import net.starlark.java.annot.Param;
+import net.starlark.java.annot.StarlarkBuiltin;
+import net.starlark.java.annot.StarlarkMethod;
+import net.starlark.java.eval.Dict;
+import net.starlark.java.eval.Starlark;
+import net.starlark.java.eval.StarlarkInt;
+import net.starlark.java.eval.StarlarkValue;
+import net.starlark.java.eval.Tuple;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for {@code native.existing_rule} and {@code native.existing_rules} functions.
+ *
+ * <p>This class covers the legacy behavior where the {@code
+ * --experimental_existing_rules_immutable_view} flag is disabled. The enabled case is covered by
+ * the subclass, {@link WithImmutableView}.
+ */
+@RunWith(JUnit4.class)
+public class NativeExistingRulesTest extends BuildViewTestCase {
+  private TestStarlarkBuiltin testStarlarkBuiltin; // initialized by createRuleClassProvider()
+
+  // Intended to be overridden by this test case's subclasses. Note that overriding of JUnit's
+  // @Before methods is not recommended.
+  protected void setupOptions() throws Exception {
+    // --noexperimental_existing_rules_immutable_view is the default; set it explicitly for clarity.
+    setBuildLanguageOptions("--noexperimental_existing_rules_immutable_view");
+  }
+
+  @Before
+  public final void setUp() throws Exception {
+    setupOptions();
+  }
+
+  @StarlarkBuiltin(name = "test")
+  private static final class TestStarlarkBuiltin implements StarlarkValue {
+
+    private final Map<String, Object> saved = new HashMap<>();
+
+    @StarlarkMethod(
+        name = "save",
+        parameters = {
+          @Param(name = "name", doc = "Name under which to save the value"),
+          @Param(name = "value", doc = "Value to save")
+        },
+        doc = "Saves a Starlark value for testing from Java")
+    public synchronized void save(String name, Object value) {
+      saved.put(name, value);
+    }
+  }
+
+  @Override
+  protected ConfiguredRuleClassProvider createRuleClassProvider() {
+    ConfiguredRuleClassProvider.Builder builder = new ConfiguredRuleClassProvider.Builder();
+    TestRuleClassProvider.addStandardRules(builder);
+    testStarlarkBuiltin = new TestStarlarkBuiltin();
+    builder.addStarlarkAccessibleTopLevels("test", testStarlarkBuiltin);
+    return builder.build();
+  }
+
+  private Object getSaved(String name) {
+    return testStarlarkBuiltin.saved.get(name);
+  }
+
+  @Test
+  public void existingRule_handlesSelect() throws Exception {
+    scratch.file("test/starlark/BUILD");
+    scratch.file(
+        "test/starlark/rulestr.bzl", //
+        "def rule_dict(name):",
+        "  return native.existing_rule(name)");
+
+    scratch.file(
+        "test/getrule/BUILD",
+        "load('//test/starlark:rulestr.bzl', 'rule_dict')",
+        "cc_library(",
+        "    name ='x',",
+        "    srcs = select({'//conditions:default': []}),",
+        ")",
+        "rule_dict('x')");
+
+    // Parse the BUILD file, to make sure select() makes it out of native.existing_rule().
+    assertThat(getConfiguredTarget("//test/getrule:x")).isNotNull();
+  }
+
+  @Test
+  public void existingRule_returnsNone() throws Exception {
+    scratch.file(
+        "test/rulestr.bzl",
+        "def test_rule(name, x):",
+        "  print(native.existing_rule(x))",
+        "  if native.existing_rule(x) == None:",
+        "    native.cc_library(name = name)");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:rulestr.bzl', 'test_rule')",
+        "test_rule('a', 'does not exist')",
+        "test_rule('b', 'BUILD')"); // exists, but as a target and not a rule
+
+    assertThat(getConfiguredTarget("//test:a")).isNotNull();
+    assertThat(getConfiguredTarget("//test:b")).isNotNull();
+  }
+
+  @Test
+  public void existingRule_roundTripsSelect() throws Exception {
+    scratch.file(
+        "test/existing_rule.bzl",
+        "def macro():",
+        "  s = select({'//foo': ['//bar']})",
+        "  print('Passed: ' + repr(s))",
+        "  native.cc_library(name = 'x', srcs = s)",
+        "  print('Returned: ' + repr(native.existing_rule('x')['srcs']))",
+        // The value returned here should round-trip fine.
+        "  native.cc_library(name = 'y', srcs = native.existing_rule('x')['srcs'])");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:existing_rule.bzl', 'macro')",
+        "macro()",
+        "cc_library(name = 'a', srcs = [])");
+    getConfiguredTarget("//test:a");
+    assertContainsEvent("Passed: select({\"//foo\": [\"//bar\"]}");
+    // The short labels are now in their canonical form, and the sequence is represented as
+    // tuple instead of list, but the meaning is unchanged.
+    assertContainsEvent("Returned: select({\"//foo:foo\": (\"//bar:bar\",)}");
+  }
+
+  @Test
+  public void existingRule_shortensLabelsInSamePackage() throws Exception {
+    scratch.file(
+        "test/existing_rule.bzl",
+        "def save_deps():",
+        "  r = native.existing_rule('b')",
+        "  test.save(\"r['deps']\", r['deps'])");
+    scratch.file(
+        "test/BUILD",
+        "load('//test:existing_rule.bzl', 'save_deps')",
+        "cc_library(name = 'a', srcs = [])",
+        "cc_binary(name = 'b', deps = ['//test:a'])",
+        "save_deps()");
+    getConfiguredTarget("//test:b");
+    assertThat(Starlark.toIterable(getSaved("r['deps']")))
+        .containsExactly(":a"); // as opposed to "//test:a"
+  }
+
+  @Test
+  public void existingRules_findsRulesAndAttributes() throws Exception {
+    scratch.file("test/BUILD");
+    scratch.file("test/starlark/BUILD");
+    scratch.file(
+        "test/starlark/rulestr.bzl",
+        "def rule_dict(name):",
+        "  return native.existing_rule(name)",
+        "def rules_dict():",
+        "  return native.existing_rules()",
+        "def nop(ctx):",
+        "  pass",
+        "nop_rule = rule(attrs = {'x': attr.label()}, implementation = nop)",
+        "def test_save(name, value):",
+        "  test.save(name, value)");
+
+    scratch.file(
+        "test/getrule/BUILD",
+        "load('//test/starlark:rulestr.bzl', 'rules_dict', 'rule_dict', 'nop_rule', 'test_save')",
+        "genrule(name = 'a', outs = ['a.txt'], ",
+        "        licenses = ['notice'],",
+        "        output_to_bindir = False,",
+        "        tools = [ '//test:bla' ], cmd = 'touch $@')",
+        "nop_rule(name = 'c', x = ':a')",
+        "rlist = rules_dict()",
+        "test_save('all_str', [rlist['a']['kind'], rlist['a']['name'],",
+        "                      rlist['c']['kind'], rlist['c']['name']])",
+        "adict = rule_dict('a')",
+        "cdict = rule_dict('c')",
+        "test_save('a_str', [adict['kind'], adict['name'], adict['outs'][0], adict['tools'][0]])",
+        "test_save('c_str', [cdict['kind'], cdict['name'], cdict['x']])",
+        "test_save('adict.keys()', adict.keys())");
+
+    getConfiguredTarget("//test/getrule:BUILD");
+    assertThat(Starlark.toIterable(getSaved("all_str")))
+        .containsExactly("genrule", "a", "nop_rule", "c").inOrder();
+    assertThat(Starlark.toIterable(getSaved("a_str")))
+        .containsExactly("genrule", "a", ":a.txt", "//test:bla").inOrder();
+    assertThat(Starlark.toIterable(getSaved("c_str")))
+        .containsExactly("nop_rule", "c", ":a")
+        .inOrder();
+    assertThat(Starlark.toIterable(getSaved("adict.keys()")))
+        .containsAtLeast(
+            "name",
+            "visibility",
+            "transitive_configs",
+            "tags",
+            "generator_name",
+            "generator_function",
+            "generator_location",
+            "features",
+            "compatible_with",
+            "target_compatible_with",
+            "restricted_to",
+            "srcs",
+            "tools",
+            "toolchains",
+            "outs",
+            "cmd",
+            "output_to_bindir",
+            "local",
+            "message",
+            "executable",
+            "stamp",
+            "heuristic_label_expansion",
+            "kind");
+  }
+
+  @Test
+  public void existingRule_returnsObjectWithCorrectMutability() throws Exception {
+    scratch.file(
+        "test/BUILD",
+        "load('inc.bzl', 'f')", //
+        "f()");
+    scratch.file(
+        "test/inc.bzl", //
+        "def f():",
+        "  native.config_setting(name='x', define_values={'key': 'value'})",
+        "  r = native.existing_rule('x')",
+        "  r['no_such_attribute'] = 'foo'",
+        "  r['define_values']['key'] = 123"); // mutate the dict
+
+    assertThat(getConfiguredTarget("//test:BUILD")).isNotNull(); // no error on mutation
+  }
+
+  @Test
+  public void existingRule_returnsDictLikeObject() throws Exception {
+    scratch.file(
+        "test/BUILD",
+        "load('inc.bzl', 'f')", //
+        "f()");
+    scratch.file(
+        "test/inc.bzl", //
+        "def f():",
+        "  native.config_setting(name='x', define_values={'key': 'value'})",
+        "  r = native.existing_rule('x')",
+        "  print('r == %s' % repr(r))",
+        "  test.save('[key for key in r]', [key for key in r])",
+        "  test.save('list(r)', list(r))",
+        "  test.save('r.keys()', r.keys())",
+        "  test.save('r.values()', r.values())",
+        "  test.save('r.items()', r.items())",
+        "  test.save(\"r['define_values']\", r['define_values'])",
+        "  test.save(\"r.get('define_values', 123)\", r.get('define_values', 123))",
+        "  test.save(\"r.get('invalid_attr', 123)\", r.get('invalid_attr', 123))",
+        "  test.save(\"'define_values' in r\", 'define_values' in r)",
+        "  test.save(\"'invalid_attr' in r\", 'invalid_attr' in r)");
+
+    Dict<?, ?> expectedDefineValues = Dict.builder().put("key", "value").buildImmutable();
+    assertThat(getConfiguredTarget("//test:BUILD")).isNotNull(); // no error
+    assertThat(Starlark.toIterable(getSaved("[key for key in r]")))
+        .containsAtLeast("define_values", "name", "kind");
+    assertThat(Starlark.toIterable(getSaved("list(r)")))
+        .containsAtLeast("define_values", "name", "kind");
+    assertThat(Starlark.toIterable(getSaved("r.keys()")))
+        .containsAtLeast("define_values", "name", "kind");
+    assertThat(Starlark.toIterable(getSaved("r.values()")))
+        .containsAtLeast(expectedDefineValues, "x", "config_setting");
+    assertThat(Starlark.toIterable(getSaved("r.items()")))
+        .containsAtLeast(
+            Tuple.of("define_values", expectedDefineValues),
+            Tuple.of("name", "x"),
+            Tuple.of("kind", "config_setting"));
+    assertThat(getSaved("r['define_values']")).isEqualTo(expectedDefineValues);
+    assertThat(getSaved("r.get('define_values', 123)")).isEqualTo(expectedDefineValues);
+    assertThat(getSaved("r.get('invalid_attr', 123)")).isEqualTo(StarlarkInt.of(123));
+    assertThat(getSaved("'define_values' in r")).isEqualTo(true);
+    assertThat(getSaved("'invalid_attr' in r")).isEqualTo(false);
+  }
+
+  @Test
+  public void existingRules_returnsObjectWithCorrectMutability() throws Exception {
+    scratch.file(
+        "test/BUILD",
+        "load('inc.bzl', 'f')", //
+        "f()");
+    scratch.file(
+        "test/inc.bzl", //
+        "def f():",
+        "  native.config_setting(name='x', define_values={'key': 'value'})",
+        "  rs = native.existing_rules()",
+        "  rs['no_such_rule'] = {'name': 'no_such_rule', 'kind': 'config_setting'}"); // mutate
+
+    assertThat(getConfiguredTarget("//test:BUILD")).isNotNull(); // no error on mutation
+  }
+
+  @Test
+  public void existingRules_returnsDictLikeObject() throws Exception {
+    scratch.file(
+        "test/BUILD",
+        "load('inc.bzl', 'f')", //
+        "f()");
+    scratch.file(
+        "test/inc.bzl", //
+        "def f():",
+        "  native.config_setting(name='x', define_values={'key_x': 'value_x'})",
+        "  native.config_setting(name='y', define_values={'key_y': 'value_y'})",
+        "  rs = native.existing_rules()",
+        "  print('rs == %s' % repr(rs))",
+        "  test.save('[key for key in rs]', [key for key in rs])",
+        "  test.save('list(rs)', list(rs))",
+        "  test.save('rs.keys()', rs.keys())",
+        "  test.save(\"[v['name'] for v in rs.values()]\", [v['name'] for v in rs.values()])",
+        "  test.save(\"[(i[0], i[1]['name']) for i in rs.items()]\", [(i[0], i[1]['name']) for i in"
+            + " rs.items()])",
+        "  test.save(\"rs['x']['define_values']\", rs['x']['define_values'])",
+        "  test.save(\"rs.get('x', {'name': 'z'})['name']\", rs.get('x', {'name': 'z'})['name'])",
+        "  test.save(\"rs.get('invalid_rule', {'name': 'invalid_rule'})\", rs.get('invalid_rule',"
+            + " {'name': 'invalid_rule'}))",
+        "  test.save(\"'x' in rs\", 'x' in rs)",
+        "  test.save(\"'invalid_rule' in rs\", 'invalid_rule' in rs)");
+
+    assertThat(getConfiguredTarget("//test:BUILD")).isNotNull(); // no error
+    assertThat(Starlark.toIterable(getSaved("[key for key in rs]"))).containsExactly("x", "y");
+    assertThat(Starlark.toIterable(getSaved("list(rs)"))).containsExactly("x", "y");
+    assertThat(Starlark.toIterable(getSaved("rs.keys()"))).containsExactly("x", "y");
+    assertThat(Starlark.toIterable(getSaved("[v['name'] for v in rs.values()]")))
+        .containsExactly("x", "y");
+    assertThat(Starlark.toIterable(getSaved("[(i[0], i[1]['name']) for i in rs.items()]")))
+        .containsExactly(Tuple.of("x", "x"), Tuple.of("y", "y"));
+    assertThat(getSaved("rs['x']['define_values']"))
+        .isEqualTo(Dict.builder().put("key_x", "value_x").buildImmutable());
+    assertThat(getSaved("rs.get('x', {'name': 'z'})['name']")).isEqualTo("x");
+    assertThat(getSaved("rs.get('invalid_rule', {'name': 'invalid_rule'})"))
+        .isEqualTo(Dict.builder().put("name", "invalid_rule").buildImmutable());
+    assertThat(getSaved("'x' in rs")).isEqualTo(true);
+    assertThat(getSaved("'invalid_rule' in rs")).isEqualTo(false);
+  }
+
+  @Test
+  public void existingRules_returnsSnapshotOfOnlyRulesInstantiatedUpToThatPoint() throws Exception {
+    scratch.file(
+        "test/BUILD",
+        "load('inc.bzl', 'f')", //
+        "f()");
+    scratch.file(
+        "test/inc.bzl", //
+        "def f():",
+        "  native.config_setting(name='x', define_values={'key_x': 'value_x'})",
+        "  rs1 = native.existing_rules()",
+        "  native.config_setting(name='y', define_values={'key_y': 'value_y'})",
+        "  rs2 = native.existing_rules()",
+        "  native.config_setting(name='z', define_values={'key_z': 'value_z'})",
+        "  rs3 = native.existing_rules()",
+        "  test.save('rs1.keys()', rs1.keys())",
+        "  test.save('rs2.keys()', rs2.keys())",
+        "  test.save('rs3.keys()', rs3.keys())");
+
+    assertThat(getConfiguredTarget("//test:BUILD")).isNotNull(); // no error
+    assertThat(Starlark.toIterable(getSaved("rs1.keys()"))).containsExactly("x");
+    assertThat(Starlark.toIterable(getSaved("rs2.keys()"))).containsExactly("x", "y");
+    assertThat(Starlark.toIterable(getSaved("rs3.keys()"))).containsExactly("x", "y", "z");
+  }
+
+  /**
+   * Tests for {@code native.existing_rule} and {@code native.existing_rules} Starlark functions
+   * with the {@code --experimental_existing_rules_immutable_view} flag set.
+   */
+  @RunWith(JUnit4.class)
+  public static final class WithImmutableView extends NativeExistingRulesTest {
+
+    @Override
+    protected void setupOptions() throws Exception {
+      setBuildLanguageOptions("--experimental_existing_rules_immutable_view");
+    }
+
+    @Test
+    @Override
+    public void existingRule_returnsObjectWithCorrectMutability() throws Exception {
+      scratch.file(
+          "test/BUILD",
+          "load('inc.bzl', 'f')", //
+          "f()");
+      scratch.file(
+          "test/inc.bzl", //
+          "def f():",
+          "  native.config_setting(name='x', define_values={'key': 'value'})",
+          "  r = native.existing_rule('x')",
+          "  r['no_such_attribute'] = 123"); // mutate the view
+
+      reporter.removeHandler(failFastHandler);
+      assertThat(getConfiguredTarget("//test:BUILD")).isNull(); // mutation fails
+      assertContainsEvent("can only assign an element in a dictionary or a list");
+    }
+
+    @Test
+    @Override
+    public void existingRules_returnsObjectWithCorrectMutability() throws Exception {
+      scratch.file(
+          "test/BUILD",
+          "load('inc.bzl', 'f')", //
+          "f()");
+      scratch.file(
+          "test/inc.bzl", //
+          "def f():",
+          "  native.config_setting(name='x', define_values={'key': 'value'})",
+          "  rs = native.existing_rules()",
+          "  rs['no_such_rule'] = {'name': 'no_such_rule', 'kind': 'config_setting'}"); // mutate
+
+      reporter.removeHandler(failFastHandler);
+      assertThat(getConfiguredTarget("//test:BUILD")).isNull(); // mutation fails
+      assertContainsEvent("can only assign an element in a dictionary or a list");
+    }
+
+    @Test
+    public void existingRules_returnsDeeplyImmutableView() throws Exception {
+      scratch.file(
+          "test/BUILD",
+          "load('inc.bzl', 'f')", //
+          "f()");
+      scratch.file(
+          "test/inc.bzl", //
+          "def f():",
+          "  native.config_setting(name='x', define_values={'key': 'value'})",
+          "  rs = native.existing_rules()",
+          "  rs['x']['define_values']['key'] = 123"); // mutate an attribute value within the view
+
+      reporter.removeHandler(failFastHandler);
+      assertThat(getConfiguredTarget("//test:BUILD")).isNull();
+      assertContainsEvent("trying to mutate a frozen dict value");
+    }
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/packages/SnapshottableBiMapTest.java b/src/test/java/com/google/devtools/build/lib/packages/SnapshottableBiMapTest.java
new file mode 100644
index 0000000..efd58f9
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/packages/SnapshottableBiMapTest.java
@@ -0,0 +1,396 @@
+// Copyright 2021 The Bazel 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.
+package com.google.devtools.build.lib.packages;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.ImmutableBiMap;
+import com.google.common.collect.ImmutableMap;
+import java.util.AbstractMap;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link SnapshottableBiMap}. */
+@RunWith(JUnit4.class)
+public final class SnapshottableBiMapTest {
+  // Dummy value type for maps under test. AutoValue for correct hash/equals behavior.
+  @AutoValue
+  abstract static class Value {
+    static Value trackedOf(String name) {
+      return new AutoValue_SnapshottableBiMapTest_Value(name, true);
+    }
+
+    static Value untrackedOf(String name) {
+      return new AutoValue_SnapshottableBiMapTest_Value(name, false);
+    }
+
+    static boolean track(Value value) {
+      return value.tracked();
+    }
+
+    abstract String name();
+
+    abstract boolean tracked();
+  }
+
+  private static <E> void verifyCollectionSizeAndContentsInOrder(
+      Collection<E> collection, Collection<E> expected) {
+    // Exhaustive testing of a collection's methods; we cannot rely on a minimal usual set of JUnit
+    // helpers because we want to verify that the collection has valid Collection semantics.
+    if (expected.isEmpty()) {
+      assertThat(collection).isEmpty();
+    } else {
+      assertThat(collection).isNotEmpty();
+    }
+    assertThat(collection).hasSize(expected.size());
+    assertThat(collection).containsExactlyElementsIn(expected).inOrder();
+    for (E entry : expected) {
+      // JUnit's containsExactlyElementsIn iterates over the collection under test, but doesn't call
+      // its contains() method.
+      assertThat(collection).contains(entry);
+    }
+  }
+
+  private static <K, V> void verifyMapSizeAndContentsInOrder(Map<K, V> map, Map<K, V> expectedMap) {
+    // Exhaustive testing of a map's methods; we cannot rely on a minimal usual set of JUnit helpers
+    // because we want to verify that the map has valid Map semantics.
+    if (expectedMap.isEmpty()) {
+      assertThat(map).isEmpty();
+    } else {
+      assertThat(map).isNotEmpty();
+    }
+
+    assertThat(map).hasSize(expectedMap.size());
+    assertThat(map).containsExactlyEntriesIn(expectedMap).inOrder();
+
+    for (Map.Entry<K, V> entry : expectedMap.entrySet()) {
+      assertThat(map.containsKey(entry.getKey()))
+          .isTrue(); // JUnit's containsKey implementation does not explicitly call map.containsKey
+      assertThat(map.containsValue(entry.getValue())).isTrue();
+    }
+
+    verifyCollectionSizeAndContentsInOrder(map.entrySet(), expectedMap.entrySet());
+    verifyCollectionSizeAndContentsInOrder(map.keySet(), expectedMap.keySet());
+    verifyCollectionSizeAndContentsInOrder(map.values(), expectedMap.values());
+  }
+
+  @SuppressWarnings("unchecked") // test-only convenience vararg transformation
+  private static <K, V> void verifyMapSizeAndContentsInOrder(
+      Map<K, V> map, K key0, V value0, Object... rest) {
+    ImmutableMap.Builder<K, V> expectedBuilder = ImmutableMap.builder();
+    expectedBuilder.put(key0, value0);
+    Preconditions.checkArgument(
+        rest.length % 2 == 0, "rest must be a flattened list of key-value pairs");
+    for (int i = 0; i < rest.length; i += 2) {
+      expectedBuilder.put((K) rest[i], (V) rest[i + 1]);
+    }
+    Map<K, V> expectedMap = expectedBuilder.build();
+    verifyMapSizeAndContentsInOrder(map, expectedMap);
+  }
+
+  private static <K, V> void verifyMapDoesNotContainEntry(Map<K, V> map, K key, V value) {
+    Map.Entry<K, V> entry = new AbstractMap.SimpleEntry<>(key, value);
+
+    // Exhaustive testing of a map's methods; we cannot rely on a minimal usual set of JUnit helpers
+    // because we want to verify that the map has valid Map semantics.
+    assertThat(map.containsKey(key))
+        .isFalse(); // JUnit's containsKey implementation does not explicitly call map.containsKeys
+    assertThat(map.containsValue(value)).isFalse();
+    assertThat(map.entrySet()).doesNotContain(entry);
+    assertThat(map.keySet()).doesNotContain(key);
+    assertThat(map.values()).doesNotContain(value);
+  }
+
+  private static <K, V> void verifyMapIsEmpty(Map<K, V> map) {
+    verifyMapSizeAndContentsInOrder(map, ImmutableMap.of());
+  }
+
+  private static <E> void verifyIteratorDoesNotAllowDeletions(Iterator<E> iterator) {
+    while (iterator.hasNext()) {
+      iterator.next();
+      assertThrows(UnsupportedOperationException.class, iterator::remove);
+    }
+  }
+
+  private static <K, V> void verifyMapDoesNotAllowDeletions(Map<K, V> map) {
+    for (Map.Entry<K, V> entry : map.entrySet()) {
+      K key = entry.getKey();
+      V value = entry.getValue();
+      assertThrows(UnsupportedOperationException.class, () -> map.remove(key));
+      assertThrows(UnsupportedOperationException.class, () -> map.keySet().remove(key));
+      assertThrows(UnsupportedOperationException.class, () -> map.values().remove(value));
+      assertThrows(UnsupportedOperationException.class, () -> map.entrySet().remove(entry));
+    }
+
+    verifyIteratorDoesNotAllowDeletions(map.keySet().iterator());
+    verifyIteratorDoesNotAllowDeletions(map.values().iterator());
+    verifyIteratorDoesNotAllowDeletions(map.entrySet().iterator());
+
+    assertThrows(UnsupportedOperationException.class, map::clear);
+  }
+
+  @SuppressWarnings("unchecked") // test-only convenience vararg transformation
+  private static <K, V> void verifyBiMapSizeAndContentsInOrder(
+      BiMap<K, V> bimap, K key0, V value0, Object... rest) {
+    ImmutableBiMap.Builder<K, V> expectedBuilder = ImmutableBiMap.builder();
+    expectedBuilder.put(key0, value0);
+    Preconditions.checkArgument(
+        rest.length % 2 == 0, "rest must be a flattened list of key-value pairs");
+    for (int i = 0; i < rest.length; i += 2) {
+      expectedBuilder.put((K) rest[i], (V) rest[i + 1]);
+    }
+    BiMap<K, V> expectedBiMap = expectedBuilder.build();
+    verifyMapSizeAndContentsInOrder(bimap, expectedBiMap);
+    verifyMapSizeAndContentsInOrder(bimap.inverse(), expectedBiMap.inverse());
+  }
+
+  private static <K, V> void verifyBiMapIsEmpty(BiMap<K, V> bimap) {
+    verifyMapSizeAndContentsInOrder(bimap, ImmutableMap.of());
+    verifyMapSizeAndContentsInOrder(bimap.inverse(), ImmutableMap.of());
+  }
+
+  @Test
+  public void containsInsertedEntries() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    verifyBiMapIsEmpty(map);
+    Value a = Value.trackedOf("a");
+    Value b = Value.untrackedOf("b");
+    Value c = Value.trackedOf("c");
+    Value z = Value.trackedOf("z");
+
+    map.put("a", a);
+    verifyBiMapSizeAndContentsInOrder(map, "a", a);
+
+    map.put("b", b);
+    verifyBiMapSizeAndContentsInOrder(map, "a", a, "b", b);
+
+    map.put("c", c);
+    verifyBiMapSizeAndContentsInOrder(map, "a", a, "b", b, "c", c);
+
+    // verify that the map's various contains*() methods don't always return true.
+    verifyMapDoesNotContainEntry(map, "z", z);
+  }
+
+  @Test
+  public void put_replacesEntries() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    Value trackedA = Value.trackedOf("a");
+    Value replaceA = Value.trackedOf("replace a");
+    Value untrackedB = Value.untrackedOf("b");
+    Value replaceB = Value.untrackedOf("b");
+
+    map.put("a", trackedA);
+    map.put("a", replaceA);
+    map.put("b", untrackedB);
+    map.put("b", replaceB);
+    verifyBiMapSizeAndContentsInOrder(map, "a", replaceA, "b", replaceB);
+  }
+
+  @Test
+  public void put_nonUniqueValue_illegal() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    Value tracked = Value.trackedOf("a");
+    Value untracked = Value.untrackedOf("b");
+
+    map.put("a", tracked);
+    assertThrows(IllegalArgumentException.class, () -> map.put("aa", tracked));
+    map.put("b", untracked);
+    assertThrows(IllegalArgumentException.class, () -> map.put("bb", untracked));
+  }
+
+  @Test
+  public void put_replacingUntrackedWithTracked_legal() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    Value tracked = Value.trackedOf("a");
+    Value untracked = Value.untrackedOf("A");
+
+    map.getTrackedSnapshot(); // start tracking
+    map.put("a", untracked);
+    map.put("a", tracked);
+    verifyBiMapSizeAndContentsInOrder(map, "a", tracked);
+  }
+
+  @Test
+  public void put_replacingTrackedWithUntracked_illegal() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    Value tracked = Value.trackedOf("a");
+    Value untracked = Value.untrackedOf("A");
+
+    map.getTrackedSnapshot(); // start tracking
+    map.put("a", tracked);
+    assertThrows(IllegalArgumentException.class, () -> map.put("a", untracked));
+  }
+
+  @Test
+  @SuppressWarnings("deprecation") // test verifying that deprecated methods don't work
+  public void deletions_unsupported() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    Value value = Value.trackedOf("a");
+    Value replacement = Value.trackedOf("replacement a");
+
+    map.put("a", value);
+    verifyMapDoesNotAllowDeletions(map);
+    verifyMapDoesNotAllowDeletions(map.inverse());
+    assertThrows(UnsupportedOperationException.class, () -> map.forcePut("a", replacement));
+    assertThrows(UnsupportedOperationException.class, () -> map.inverse().forcePut(value, "aa"));
+  }
+
+  @Test
+  public void getUnderlyingBiMap_returnsBiMapSupportingRemove() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    Value a = Value.trackedOf("a");
+    Value b = Value.untrackedOf("b");
+    Value c = Value.trackedOf("c");
+
+    map.put("a", a);
+    map.put("b", b);
+    map.put("c", c);
+    BiMap<String, Value> underlying = map.getUnderlyingBiMap();
+    verifyBiMapSizeAndContentsInOrder(underlying, "a", a, "b", b, "c", c);
+
+    underlying.remove("a");
+    verifyBiMapSizeAndContentsInOrder(underlying, "b", b, "c", c);
+  }
+
+  @Test
+  public void snapshot_containsExpectedEntries() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    Value trackedA = Value.trackedOf("a");
+    Value untrackedB = Value.untrackedOf("b");
+    Value trackedC = Value.trackedOf("c");
+    Value z = Value.trackedOf("z");
+
+    Map<String, Value> snapshot0 = map.getTrackedSnapshot();
+    verifyMapIsEmpty(snapshot0);
+
+    map.put("a", trackedA);
+    Map<String, Value> snapshot1 = map.getTrackedSnapshot();
+    verifyMapIsEmpty(snapshot0);
+    verifyMapSizeAndContentsInOrder(snapshot1, "a", trackedA);
+
+    map.put("b", untrackedB);
+    Map<String, Value> snapshot2 = map.getTrackedSnapshot();
+    verifyMapIsEmpty(snapshot0);
+    verifyMapSizeAndContentsInOrder(snapshot1, "a", trackedA);
+    verifyMapSizeAndContentsInOrder(snapshot2, "a", trackedA); // b is untracked
+
+    map.put("c", Value.trackedOf("c"));
+    Map<String, Value> snapshot3 = map.getTrackedSnapshot();
+    verifyMapIsEmpty(snapshot0);
+    verifyMapSizeAndContentsInOrder(snapshot1, "a", trackedA); // c was added after snapshot
+    verifyMapSizeAndContentsInOrder(snapshot2, "a", trackedA);
+    verifyMapSizeAndContentsInOrder(snapshot3, "a", trackedA, "c", trackedC);
+
+    // verify that a snapshot's various contains*() methods don't always return true.
+    verifyMapDoesNotContainEntry(snapshot1, "z", z);
+    verifyMapDoesNotContainEntry(snapshot2, "z", z);
+    verifyMapDoesNotContainEntry(snapshot3, "z", z);
+  }
+
+  @Test
+  public void snapshot_isUnmodifiable() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    map.put("a", Value.trackedOf("a"));
+    map.put("b", Value.untrackedOf("b"));
+    map.put("c", Value.trackedOf("c"));
+    Map<String, Value> snapshot = map.getTrackedSnapshot();
+
+    verifyMapDoesNotAllowDeletions(snapshot);
+    assertThrows(
+        UnsupportedOperationException.class, () -> snapshot.put("a", Value.trackedOf("replace a")));
+    assertThrows(
+        UnsupportedOperationException.class, () -> snapshot.put("d", Value.trackedOf("d")));
+  }
+
+  @Test
+  public void snapshot_containsReplacementsPerformedBeforeSnapshotCreation() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    Value trackedA = Value.trackedOf("a");
+    Value replacementA = Value.trackedOf("replacement a");
+    Value untrackedB = Value.untrackedOf("b");
+    Value replacementB = Value.trackedOf("replacement b");
+
+    map.put("a", trackedA);
+    map.put("b", untrackedB);
+    verifyMapSizeAndContentsInOrder(map, "a", trackedA, "b", untrackedB);
+    map.put("a", replacementA);
+    map.put("b", replacementB);
+    verifyMapSizeAndContentsInOrder(map, "a", replacementA, "b", replacementB);
+
+    Map<String, Value> snapshot = map.getTrackedSnapshot();
+    verifyMapSizeAndContentsInOrder(snapshot, "a", replacementA, "b", replacementB);
+  }
+
+  @Test
+  public void snapshot_afterReplacingEntryInSnapshot_containsReplacement() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    Value original = Value.trackedOf("a");
+    Value replacement = Value.trackedOf("replacement a");
+
+    map.put("a", original);
+    Map<String, Value> snapshot = map.getTrackedSnapshot();
+    verifyMapSizeAndContentsInOrder(snapshot, "a", original);
+
+    map.put("a", replacement);
+    verifyMapSizeAndContentsInOrder(snapshot, "a", replacement);
+  }
+
+  @Test
+  public void snapshot_afterReplacingEntryNotInSnapshot_doesNotContainReplacement() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    Value untrackedA = Value.untrackedOf("a");
+    Value replacementA = Value.trackedOf("replacement a");
+    Value trackedB = Value.trackedOf("b");
+    Value replacementB = Value.trackedOf("replacement b");
+
+    map.put("a", untrackedA);
+    Map<String, Value> snapshot = map.getTrackedSnapshot();
+    verifyMapIsEmpty(snapshot);
+
+    map.put("a", replacementA);
+    map.put("b", trackedB);
+    map.put("b", replacementB);
+    verifyMapSizeAndContentsInOrder(map, "a", replacementA, "b", replacementB);
+    verifyMapIsEmpty(snapshot);
+  }
+
+  @Test
+  public void snapshot_containsReplacementEntries_inOriginalKeyInsertionOrder() {
+    SnapshottableBiMap<String, Value> map = new SnapshottableBiMap<>(Value::track);
+    Value a = Value.trackedOf("a");
+    Value b = Value.trackedOf("b");
+    Value replaceB = Value.trackedOf("replacement b");
+    Value c = Value.trackedOf("c");
+    Value replaceC = Value.trackedOf("replacement c");
+
+    map.put("a", a);
+    map.put("b", b);
+    map.put("c", c);
+
+    Map<String, Value> snapshot = map.getTrackedSnapshot();
+    verifyMapSizeAndContentsInOrder(snapshot, "a", a, "b", b, "c", c);
+
+    map.put("c", replaceC);
+    map.put("b", replaceB);
+    verifyMapSizeAndContentsInOrder(snapshot, "a", a, "b", replaceB, "c", replaceC);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/starlark/StarlarkRuleContextTest.java b/src/test/java/com/google/devtools/build/lib/starlark/StarlarkRuleContextTest.java
index ecc2ae3..fd40d3d 100644
--- a/src/test/java/com/google/devtools/build/lib/starlark/StarlarkRuleContextTest.java
+++ b/src/test/java/com/google/devtools/build/lib/starlark/StarlarkRuleContextTest.java
@@ -519,158 +519,6 @@
   }
 
   @Test
-  public void testGetRuleSelect() throws Exception {
-    scratch.file("test/starlark/BUILD");
-    scratch.file(
-        "test/starlark/rulestr.bzl", "def rule_dict(name):", "  return native.existing_rule(name)");
-
-    scratch.file(
-        "test/getrule/BUILD",
-        "load('//test/starlark:rulestr.bzl', 'rule_dict')",
-        "cc_library(name ='x', ",
-        "  srcs = select({'//conditions:default': []})",
-        ")",
-        "rule_dict('x')");
-
-    // Parse the BUILD file, to make sure select() makes it out of native.rule().
-    createRuleContext("//test/getrule:x");
-  }
-
-  @Test
-  public void testExistingRuleReturnNone() throws Exception {
-    scratch.file(
-        "test/rulestr.bzl",
-        "def test_rule(name, x):",
-        "  print(native.existing_rule(x))",
-        "  if native.existing_rule(x) == None:",
-        "    native.cc_library(name = name)");
-    scratch.file(
-        "test/BUILD",
-        "load('//test:rulestr.bzl', 'test_rule')",
-        "test_rule('a', 'does not exist')",
-        "test_rule('b', 'BUILD')");
-
-    assertThat(getConfiguredTarget("//test:a")).isNotNull();
-    assertThat(getConfiguredTarget("//test:b")).isNotNull();
-  }
-
-  @Test
-  public void existingRuleWithSelect() throws Exception {
-    scratch.file(
-        "test/existing_rule.bzl",
-        "def macro():",
-        "  s = select({'//foo': ['//bar']})",
-        "  print('Passed: ' + repr(s))",
-        "  native.cc_library(name = 'x', srcs = s)",
-        "  print('Returned: ' + repr(native.existing_rule('x')['srcs']))",
-        // The value returned here should round-trip fine.
-        "  native.cc_library(name = 'y', srcs = native.existing_rule('x')['srcs'])");
-    scratch.file(
-        "test/BUILD",
-        "load('//test:existing_rule.bzl', 'macro')",
-        "macro()",
-        "cc_library(name = 'a', srcs = [])");
-    getConfiguredTarget("//test:a");
-    assertContainsEvent("Passed: select({\"//foo\": [\"//bar\"]}");
-    // The short labels are now in their canonical form, and the sequence is represented as
-    // tuple instead of list, but the meaning is unchanged.
-    assertContainsEvent("Returned: select({\"//foo:foo\": (\"//bar:bar\",)}");
-  }
-
-  @Test
-  public void testGetRule() throws Exception {
-    scratch.file("test/starlark/BUILD");
-    scratch.file(
-        "test/starlark/rulestr.bzl",
-        "def rule_dict(name):",
-        "  return native.existing_rule(name)",
-        "def rules_dict():",
-        "  return native.existing_rules()",
-        "def nop(ctx):",
-        "  pass",
-        "nop_rule = rule(attrs = {'x': attr.label()}, implementation = nop)",
-        "consume_rule = rule(attrs = {'s': attr.string_list()}, implementation = nop)");
-
-    scratch.file(
-        "test/getrule/BUILD",
-        "load('//test/starlark:rulestr.bzl', 'rules_dict', 'rule_dict', 'nop_rule',"
-            + "'consume_rule')",
-        "genrule(name = 'a', outs = ['a.txt'], ",
-        "        licenses = ['notice'],",
-        "        output_to_bindir = False,",
-        "        tools = [ '//test:bla' ], cmd = 'touch $@')",
-        "nop_rule(name = 'c', x = ':a')",
-        "rlist= rules_dict()",
-        "consume_rule(name = 'all_str', s = [rlist['a']['kind'], rlist['a']['name'], ",
-        "                                    rlist['c']['kind'], rlist['c']['name']])",
-        "adict = rule_dict('a')",
-        "cdict = rule_dict('c')",
-        "consume_rule(name = 'a_str', ",
-        "             s = [adict['kind'], adict['name'], adict['outs'][0], adict['tools'][0]])",
-        "consume_rule(name = 'genrule_attr', ",
-        "             s = adict.keys())",
-        "consume_rule(name = 'c_str', s = [cdict['kind'], cdict['name'], cdict['x']])");
-
-    StarlarkRuleContext allContext = createRuleContext("//test/getrule:all_str");
-    setRuleContext(allContext);
-    List<?> result = (List) ev.eval("ruleContext.attr.s");
-    assertThat(result).containsExactly("genrule", "a", "nop_rule", "c");
-
-    setRuleContext(createRuleContext("//test/getrule:a_str"));
-    result = (List) ev.eval("ruleContext.attr.s");
-    assertThat(result).containsExactly("genrule", "a", ":a.txt", "//test:bla");
-
-    setRuleContext(createRuleContext("//test/getrule:c_str"));
-    result = (List) ev.eval("ruleContext.attr.s");
-    assertThat(result).containsExactly("nop_rule", "c", ":a");
-
-    setRuleContext(createRuleContext("//test/getrule:genrule_attr"));
-    result = (List) ev.eval("ruleContext.attr.s");
-    assertThat(result)
-        .containsAtLeast(
-            "name",
-            "visibility",
-            "transitive_configs",
-            "tags",
-            "generator_name",
-            "generator_function",
-            "generator_location",
-            "features",
-            "compatible_with",
-            "target_compatible_with",
-            "restricted_to",
-            "srcs",
-            "tools",
-            "toolchains",
-            "outs",
-            "cmd",
-            "output_to_bindir",
-            "local",
-            "message",
-            "executable",
-            "stamp",
-            "heuristic_label_expansion",
-            "kind");
-  }
-
-  @Test
-  public void testExistingRuleDictIsMutable() throws Exception {
-    scratch.file(
-        "test/BUILD",
-        "load('inc.bzl', 'f')", //
-        "f()");
-    scratch.file(
-        "test/inc.bzl", //
-        "def f():",
-        "  native.config_setting(name='x', define_values={'key': 'value'})",
-        "  r = native.existing_rule('x')",
-        "  r['define_values']['key'] = 123"); // mutate the dict
-
-    // Logically this belongs among the loading-phase tests of existing_rules. Where are they?
-    assertThat(getConfiguredTarget("//test:BUILD")).isNotNull(); // no error
-  }
-
-  @Test
   public void testGetRuleAttributeListValue() throws Exception {
     StarlarkRuleContext ruleContext = createRuleContext("//foo:foo");
     setRuleContext(ruleContext);