bazel syntax: disentangle StarlarkThread and Module

New interpreter API (if you don't use a helper function):

To call a function:

   try (Mutability mu = Mutability.create("myexpr")) {
      StarlarkThread thread = new StarlarkThread(mu, semantics);
      return Starlark.call(thread, fn, args, kwargs);
   } catch (EvalException ex) {
       ...
   }

To execute a file:

   StarlarkFile file = ...
   Module module = Module.create(); // default environment
   Resolver.resolve(file, module);
   try (Mutability mu = Mutability.create("myfile")) {
      StarlarkThread thread = new StarlarkThread(mu, semantics);
      Starlark.exec(file, thread, module);
   } catch (EvalException ex) {
       ...
   }
   // Inv: module contains globals

Overview of change:

- Eliminate the concept of "a Starlark thread's module".
  A module is rightly associated with a Starlark function, not a thread.
  Consider a thread used just to call an existing function value, for example.
  (Previously, a module would always have been created even if unused.)

- Modules are now created explicitly, from a predeclared environment
  and a semantics, which is used for filtering but not retained.
  Modules can now be created before threads---the logical order.
  This simplifies a number of clients.

- Flatten Module. It is no longer a linked list. It contains only
   (predeclared, globals, clientData),
  and exportedGlobals which will go away soon.

- Simplify processing of FlagGuardedValues. They are either unwrapped
  (if enabled by semantics) or left as is, if disabled.
  This means they are visible through Module.getPredeclared.

- Delete Module.mutability. It is inessential and raises
  questions of consistency with StarlarkThread.
  What really matters is whether a module's global values are mutable.

- Delete StarlarkThread.Builder. A simple constructor now suffices:
   new StarlarkThread(Mutability, StarlarkSemantics).

- EvaluationTestCase now exposes two hooks for Module and Thread creation
  so that tests can predeclare bindings, set client data, and insert
  thread local values.  Creation of Module and Thread is now fully lazy.
  A follow-up change will eliminate the regrettable use of inheritance.

Also:

- Move ModuleCodec into Module, so that we don't need to harm its API.

- Use separate UNIVERSE and predeclared buckets in Module.
  The UNIVERSE is always implicitly available.
  The API doesn't fully separate them yet (needs Resolver work),
  but this should reduce the amount of map copying and redundant
  specification.

- Add more pre-evaluated expressions to ParamDescriptor.evalDefault
  so that we can bootstrap all the @Param annotation's default values
  used by Starlark.UNIVERSE without JVM deadlock. This breaks a cyclic
  dependency between the evaluator and UNIVERSE.

- Use composition not inheritance of EvaluationTestCase in more tests.

This is my 6th attempt at this change in as many months.

This is a breaking API change for Copybara.

PiperOrigin-RevId: 312284294
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/ConfiguredRuleClassProvider.java b/src/main/java/com/google/devtools/build/lib/analysis/ConfiguredRuleClassProvider.java
index efa8fb9..efe2d4d 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/ConfiguredRuleClassProvider.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/ConfiguredRuleClassProvider.java
@@ -730,7 +730,7 @@
       ImmutableList<Bootstrap> bootstraps) {
     ImmutableMap.Builder<String, Object> envBuilder = ImmutableMap.builder();
 
-    // Among other symbols, this step adds the Starlark universe (e.g. None/True/len), for now.
+    // Add predeclared symbols of the Bazel build language.
     StarlarkModules.addStarlarkGlobalsToBuilder(envBuilder);
 
     envBuilder.putAll(starlarkAccessibleTopLevels.entrySet());
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/config/StarlarkDefinedConfigTransition.java b/src/main/java/com/google/devtools/build/lib/analysis/config/StarlarkDefinedConfigTransition.java
index 478520e..5ab8f81 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/config/StarlarkDefinedConfigTransition.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/config/StarlarkDefinedConfigTransition.java
@@ -277,11 +277,8 @@
     private Object evalFunction(
         StarlarkCallable function, ImmutableList<Object> args, EventHandler eventHandler)
         throws InterruptedException, EvalException {
-      try (Mutability mutability = Mutability.create("eval_transition_function")) {
-        StarlarkThread thread =
-            StarlarkThread.builder(mutability)
-                .setSemantics(semantics)
-                .build();
+      try (Mutability mu = Mutability.create("eval_transition_function")) {
+        StarlarkThread thread = new StarlarkThread(mu, semantics);
         thread.setPrintHandler(Event.makeDebugPrintHandler(eventHandler));
         starlarkContext.storeInThread(thread);
         return Starlark.call(thread, function, args, /*kwargs=*/ ImmutableMap.of());
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkCustomCommandLine.java b/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkCustomCommandLine.java
index 769dbd3..c86d35e 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkCustomCommandLine.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkCustomCommandLine.java
@@ -671,12 +671,9 @@
       Location loc,
       StarlarkSemantics starlarkSemantics)
       throws CommandLineExpansionException {
-    try (Mutability mutability = Mutability.create("map_each")) {
-      StarlarkThread thread =
-          StarlarkThread.builder(mutability)
-              .setSemantics(starlarkSemantics)
-              .build();
-      // TODO(b/77140311): Error if we issue print statements
+    try (Mutability mu = Mutability.create("map_each")) {
+      StarlarkThread thread = new StarlarkThread(mu, starlarkSemantics);
+      // TODO(b/77140311): Error if we issue print statements.
       thread.setPrintHandler((th, msg) -> {});
       int count = originalValues.size();
       for (int i = 0; i < count; ++i) {
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkModules.java b/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkModules.java
index cb7ccbd..c2adbeb 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkModules.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkModules.java
@@ -22,14 +22,13 @@
 import com.google.devtools.build.lib.packages.StarlarkNativeModule;
 import com.google.devtools.build.lib.packages.StructProvider;
 import com.google.devtools.build.lib.skylarkbuildapi.TopLevelBootstrap;
-import com.google.devtools.build.lib.syntax.Starlark;
 
 /** The basis for a Starlark Environment with all build-related modules registered. */
 public final class StarlarkModules {
 
   private StarlarkModules() { }
 
-  /** A bootstrap for non-rules-specific globals of the build API. */
+  /** A bootstrap for non-rules-specific built-ins of the build API. */
   private static TopLevelBootstrap topLevelBootstrap =
       new TopLevelBootstrap(
           new BazelBuildApiGlobals(),
@@ -42,13 +41,10 @@
           ActionsProvider.INSTANCE,
           DefaultInfo.PROVIDER);
 
-  /**
-   * Adds bindings for Starlark built-ins and non-rules-specific globals of the build API to the
-   * given environment map builder.
-   */
-  public static void addStarlarkGlobalsToBuilder(ImmutableMap.Builder<String, Object> env) {
-    env.putAll(Starlark.UNIVERSE);
-    env.putAll(StarlarkLibrary.COMMON); // e.g. select, depset
-    topLevelBootstrap.addBindingsToBuilder(env);
+  /** Adds predeclared Starlark bindings for the Bazel build language. */
+  // TODO(adonovan): rename "globals" -> "builtins"
+  public static void addStarlarkGlobalsToBuilder(ImmutableMap.Builder<String, Object> predeclared) {
+    predeclared.putAll(StarlarkLibrary.COMMON); // e.g. select, depset
+    topLevelBootstrap.addBindingsToBuilder(predeclared);
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkRuleConfiguredTargetUtil.java b/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkRuleConfiguredTargetUtil.java
index ef3ebba..7fff89b 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkRuleConfiguredTargetUtil.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/skylark/StarlarkRuleConfiguredTargetUtil.java
@@ -96,12 +96,9 @@
       throws InterruptedException, RuleErrorException, ActionConflictException {
     String expectFailure = ruleContext.attributes().get("expect_failure", Type.STRING);
     StarlarkRuleContext starlarkRuleContext = null;
-    try (Mutability mutability = Mutability.create("configured target")) {
+    try (Mutability mu = Mutability.create("configured target")) {
       starlarkRuleContext = new StarlarkRuleContext(ruleContext, null, starlarkSemantics);
-      StarlarkThread thread =
-          StarlarkThread.builder(mutability)
-              .setSemantics(starlarkSemantics)
-              .build();
+      StarlarkThread thread = new StarlarkThread(mu, starlarkSemantics);
       thread.setPrintHandler(
           Event.makeDebugPrintHandler(ruleContext.getAnalysisEnvironment().getEventHandler()));
 
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/skylark/StarlarkRepositoryFunction.java b/src/main/java/com/google/devtools/build/lib/bazel/repository/skylark/StarlarkRepositoryFunction.java
index 17340c7..d1a46d2 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/repository/skylark/StarlarkRepositoryFunction.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/skylark/StarlarkRepositoryFunction.java
@@ -145,11 +145,8 @@
     ImmutableSet<PathFragment> blacklistedPatterns =
         Preconditions.checkNotNull(blacklistedPackagesValue).getPatterns();
 
-    try (Mutability mutability = Mutability.create("Starlark repository")) {
-      StarlarkThread thread =
-          StarlarkThread.builder(mutability)
-              .setSemantics(starlarkSemantics)
-              .build();
+    try (Mutability mu = Mutability.create("Starlark repository")) {
+      StarlarkThread thread = new StarlarkThread(mu, starlarkSemantics);
       thread.setPrintHandler(Event.makeDebugPrintHandler(env.getListener()));
 
       // The fetch phase does not need the tools repository
diff --git a/src/main/java/com/google/devtools/build/lib/packages/PackageFactory.java b/src/main/java/com/google/devtools/build/lib/packages/PackageFactory.java
index 5cb0542..0b96f4c 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/PackageFactory.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/PackageFactory.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.GoogleLogger;
 import com.google.devtools.build.lib.cmdline.Label;
 import com.google.devtools.build.lib.cmdline.LabelConstants;
 import com.google.devtools.build.lib.cmdline.LabelValidator;
@@ -93,8 +92,6 @@
  */
 public final class PackageFactory {
 
-  private static final GoogleLogger logger = GoogleLogger.forEnclosingClass();
-
   /** An extension to the global namespace of the BUILD language. */
   // TODO(bazel-team): this should probably be renamed PackageFactory.RuntimeExtension
   //  since really we're extending the Runtime with more classes.
@@ -642,7 +639,6 @@
   }
 
   private void populateEnvironment(ImmutableMap.Builder<String, Object> env) {
-    env.putAll(Starlark.UNIVERSE);
     env.putAll(StarlarkLibrary.BUILD); // e.g. rule, select, depset
     env.putAll(StarlarkNativeModule.BINDINGS_FOR_BUILD_FILES);
     env.put("package", newPackageFunction(packageArguments));
@@ -768,61 +764,55 @@
     }
 
     // Construct environment.
-    ImmutableMap.Builder<String, Object> env = ImmutableMap.builder();
-    populateEnvironment(env);
+    ImmutableMap.Builder<String, Object> predeclared = ImmutableMap.builder();
+    populateEnvironment(predeclared);
+    Module module = Module.withPredeclared(semantics, predeclared.build());
 
-    // TODO(adonovan): defer creation of Mutability + Thread till after validation,
-    // once the validate API is rationalized.
-    try (Mutability mutability = Mutability.create("package", packageId)) {
-      StarlarkThread thread =
-          StarlarkThread.builder(mutability)
-              .setGlobals(Module.createForBuiltins(env.build()))
-              .setSemantics(semantics)
-              .build();
+    // Validate.
+    Resolver.resolveFile(file, module);
+    if (!file.ok()) {
+      Event.replayEventsOn(pkgContext.eventHandler, file.errors());
+      return false;
+    }
+
+    // Check syntax. Make a pass over the syntax tree to:
+    // - reject forbidden BUILD syntax
+    // - extract literal glob patterns for prefetching
+    // - record the generator_name of each top-level macro call
+    Set<String> globs = new HashSet<>();
+    Set<String> globsWithDirs = new HashSet<>();
+    if (!checkBuildSyntax(
+        file,
+        globs,
+        globsWithDirs,
+        pkgBuilder.getGeneratorNameByLocation(),
+        pkgContext.eventHandler)) {
+      return false;
+    }
+
+    // Prefetch glob patterns asynchronously.
+    if (maxDirectoriesToEagerlyVisitInGlobbing == -2) {
+      try {
+        pkgContext.globber.runAsync(
+            ImmutableList.copyOf(globs),
+            ImmutableList.of(),
+            /*excludeDirs=*/ true,
+            /*allowEmpty=*/ true);
+        pkgContext.globber.runAsync(
+            ImmutableList.copyOf(globsWithDirs),
+            ImmutableList.of(),
+            /*excludeDirs=*/ false,
+            /*allowEmpty=*/ true);
+      } catch (BadGlobException ex) {
+        // Ignore exceptions.
+        // Errors will be properly reported when the actual globbing is done.
+      }
+    }
+
+    try (Mutability mu = Mutability.create("package", packageId)) {
+      StarlarkThread thread = new StarlarkThread(mu, semantics);
       thread.setLoader(loadedModules::get);
       thread.setPrintHandler(Event.makeDebugPrintHandler(pkgContext.eventHandler));
-      Module module = thread.getGlobals();
-
-      // Validate.
-      Resolver.resolveFile(file, module);
-      if (!file.ok()) {
-        Event.replayEventsOn(pkgContext.eventHandler, file.errors());
-        return false;
-      }
-
-      // Check syntax. Make a pass over the syntax tree to:
-      // - reject forbidden BUILD syntax
-      // - extract literal glob patterns for prefetching
-      // - record the generator_name of each top-level macro call
-      Set<String> globs = new HashSet<>();
-      Set<String> globsWithDirs = new HashSet<>();
-      if (!checkBuildSyntax(
-          file,
-          globs,
-          globsWithDirs,
-          pkgBuilder.getGeneratorNameByLocation(),
-          pkgContext.eventHandler)) {
-        return false;
-      }
-
-      // Prefetch glob patterns asynchronously.
-      if (maxDirectoriesToEagerlyVisitInGlobbing == -2) {
-        try {
-          pkgContext.globber.runAsync(
-              ImmutableList.copyOf(globs),
-              ImmutableList.of(),
-              /*excludeDirs=*/ true,
-              /*allowEmpty=*/ true);
-          pkgContext.globber.runAsync(
-              ImmutableList.copyOf(globsWithDirs),
-              ImmutableList.of(),
-              /*excludeDirs=*/ false,
-              /*allowEmpty=*/ true);
-        } catch (BadGlobException ex) {
-          // Ignore exceptions.
-          // Errors will be properly reported when the actual globbing is done.
-        }
-      }
 
       new BazelStarlarkContext(
               BazelStarlarkContext.Phase.LOADING,
diff --git a/src/main/java/com/google/devtools/build/lib/packages/RuleClassProvider.java b/src/main/java/com/google/devtools/build/lib/packages/RuleClassProvider.java
index 2547f00..9afa306 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/RuleClassProvider.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/RuleClassProvider.java
@@ -61,8 +61,7 @@
 
   /**
    * Returns the predeclared environment for a loading-phase thread. Includes "native", though its
-   * value may be inappropriate for a WORKSPACE file. Includes the universal bindings (e.g. True,
-   * len), though that will soon change.
+   * value may be inappropriate for a WORKSPACE file. Excludes universal bindings (e.g. True, len).
    */
   // TODO(adonovan): update doc comment. And does it really include native?
   ImmutableMap<String, Object> getEnvironment();
diff --git a/src/main/java/com/google/devtools/build/lib/packages/StarlarkCallbackHelper.java b/src/main/java/com/google/devtools/build/lib/packages/StarlarkCallbackHelper.java
index 4c2b02a..f54601a 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/StarlarkCallbackHelper.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/StarlarkCallbackHelper.java
@@ -69,11 +69,8 @@
   // Instead, make them supply a map.
   public Object call(EventHandler eventHandler, ClassObject ctx, Object... arguments)
       throws EvalException, InterruptedException {
-    try (Mutability mutability = Mutability.create("callback", callback)) {
-      StarlarkThread thread =
-          StarlarkThread.builder(mutability)
-              .setSemantics(starlarkSemantics)
-              .build();
+    try (Mutability mu = Mutability.create("callback", callback)) {
+      StarlarkThread thread = new StarlarkThread(mu, starlarkSemantics);
       thread.setPrintHandler(Event.makeDebugPrintHandler(eventHandler));
       context.storeInThread(thread);
       return Starlark.call(
diff --git a/src/main/java/com/google/devtools/build/lib/packages/WorkspaceFactory.java b/src/main/java/com/google/devtools/build/lib/packages/WorkspaceFactory.java
index c3a207d..df31189 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/WorkspaceFactory.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/WorkspaceFactory.java
@@ -157,22 +157,22 @@
       throws InterruptedException {
     loadedModules.putAll(additionalLoadedModules);
 
-    // environment
-    HashMap<String, Object> env = new HashMap<>();
-    env.putAll(getDefaultEnvironment());
-    env.putAll(bindings); // (may shadow bindings in default environment)
+    // set up predeclared environment
+    HashMap<String, Object> predeclared = new HashMap<>();
+    predeclared.putAll(getDefaultEnvironment());
+    predeclared.putAll(bindings); // (may shadow bindings in default environment)
+    Module module = Module.withPredeclared(starlarkSemantics, predeclared);
 
-    StarlarkThread thread =
-        StarlarkThread.builder(this.mutability)
-            .setSemantics(this.starlarkSemantics)
-            .setGlobals(Module.createForBuiltins(env))
-            .build();
+    // resolve
+    Resolver.resolveFile(file, module);
+
+    // create thread
+    StarlarkThread thread = new StarlarkThread(mutability, starlarkSemantics);
     thread.setLoader(loadedModules::get);
     thread.setPrintHandler(Event.makeDebugPrintHandler(localReporter));
     thread.setThreadLocal(
         PackageFactory.PackageContext.class,
         new PackageFactory.PackageContext(builder, null, localReporter));
-    Module module = thread.getGlobals();
 
     // The workspace environment doesn't need the tools repository or the fragment map
     // because executing workspace rules happens before analysis and it doesn't need a
@@ -187,7 +187,6 @@
             /*analysisRuleLabel=*/ null)
         .storeInThread(thread);
 
-    Resolver.resolveFile(file, thread.getGlobals());
     List<String> globs = new ArrayList<>(); // unused
     if (!file.ok()) {
       Event.replayEventsOn(localReporter, file.errors());
@@ -204,7 +203,7 @@
     // for use in the next chunk. This set does not include the bindings
     // added by getDefaultEnvironment; but it does include bindings created by load,
     // so we will need to set the legacy load-binds-globally flag for this file in due course.
-    this.bindings.putAll(thread.getGlobals().getBindings());
+    this.bindings.putAll(module.getGlobals());
 
     builder.addPosts(localReporter.getPosts());
     builder.addEvents(localReporter.getEvents());
@@ -358,7 +357,6 @@
 
   private ImmutableMap<String, Object> getDefaultEnvironment() {
     ImmutableMap.Builder<String, Object> env = ImmutableMap.builder();
-    env.putAll(Starlark.UNIVERSE);
     env.putAll(StarlarkLibrary.COMMON); // e.g. select, depset
     env.putAll(workspaceFunctions);
     if (installDir != null) {
diff --git a/src/main/java/com/google/devtools/build/lib/rules/repository/ResolvedFileFunction.java b/src/main/java/com/google/devtools/build/lib/rules/repository/ResolvedFileFunction.java
index a4026a6..84caf2d 100644
--- a/src/main/java/com/google/devtools/build/lib/rules/repository/ResolvedFileFunction.java
+++ b/src/main/java/com/google/devtools/build/lib/rules/repository/ResolvedFileFunction.java
@@ -29,7 +29,6 @@
 import com.google.devtools.build.lib.syntax.Mutability;
 import com.google.devtools.build.lib.syntax.ParserInput;
 import com.google.devtools.build.lib.syntax.Resolver;
-import com.google.devtools.build.lib.syntax.Starlark;
 import com.google.devtools.build.lib.syntax.StarlarkFile;
 import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
@@ -78,31 +77,24 @@
           throw resolvedValueError("Failed to parse resolved file " + key.getPath());
         }
 
-        Module resolvedModule;
-        try (Mutability mutability = Mutability.create("resolved file", key.getPath())) {
-          StarlarkThread thread =
-              StarlarkThread.builder(mutability)
-                  .setSemantics(starlarkSemantics)
-                  .setGlobals(Module.createForBuiltins(Starlark.UNIVERSE))
-                  .build();
-          resolvedModule = thread.getGlobals();
+        Module module = Module.create();
 
-          // resolve
-          Resolver.resolveFile(file, resolvedModule);
-          if (!file.ok()) {
-            Event.replayEventsOn(env.getListener(), file.errors());
-            throw resolvedValueError("Failed to validate resolved file " + key.getPath());
-          }
-
-          // execute
-          try {
-            EvalUtils.exec(file, resolvedModule, thread);
-          } catch (EvalException ex) {
-            env.getListener().handle(Event.error(ex.getLocation(), ex.getMessage()));
-            throw resolvedValueError("Failed to evaluate resolved file " + key.getPath());
-          }
+        // resolve
+        Resolver.resolveFile(file, module);
+        if (!file.ok()) {
+          Event.replayEventsOn(env.getListener(), file.errors());
+          throw resolvedValueError("Failed to validate resolved file " + key.getPath());
         }
-        Object resolved = resolvedModule.lookup("resolved");
+
+        // execute
+        try (Mutability mu = Mutability.create("resolved file", key.getPath())) {
+          StarlarkThread thread = new StarlarkThread(mu, starlarkSemantics);
+          EvalUtils.exec(file, module, thread);
+        } catch (EvalException ex) {
+          env.getListener().handle(Event.error(ex.getLocation(), ex.getMessage()));
+          throw resolvedValueError("Failed to evaluate resolved file " + key.getPath());
+        }
+        Object resolved = module.getGlobal("resolved");
         if (resolved == null) {
           throw resolvedValueError(
               "Symbol 'resolved' not exported in resolved file " + key.getPath());
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/ASTFileLookupFunction.java b/src/main/java/com/google/devtools/build/lib/skyframe/ASTFileLookupFunction.java
index 1b1c7a2..da1dd39 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/ASTFileLookupFunction.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/ASTFileLookupFunction.java
@@ -149,7 +149,8 @@
     }
 
     // resolve (and soon, compile)
-    Resolver.resolveFile(file, Module.createForBuiltins(ruleClassProvider.getEnvironment()));
+    Module module = Module.withPredeclared(semantics, ruleClassProvider.getEnvironment());
+    Resolver.resolveFile(file, module);
     Event.replayEventsOn(env.getListener(), file.errors()); // TODO(adonovan): fail if !ok()?
 
     return ASTFileLookupValue.withFile(file, digest);
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/AspectFunction.java b/src/main/java/com/google/devtools/build/lib/skyframe/AspectFunction.java
index 6812cfa..862494c 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/AspectFunction.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/AspectFunction.java
@@ -165,8 +165,7 @@
         return null;
       }
 
-      Object starlarkValue =
-          starlarkImportLookupValue.getModule().getBindings().get(starlarkValueName);
+      Object starlarkValue = starlarkImportLookupValue.getModule().getGlobal(starlarkValueName);
       if (starlarkValue == null) {
         throw new ConversionException(
             String.format(
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/StarlarkAspectFactory.java b/src/main/java/com/google/devtools/build/lib/skyframe/StarlarkAspectFactory.java
index e6fa831..acbdb27 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/StarlarkAspectFactory.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/StarlarkAspectFactory.java
@@ -58,7 +58,8 @@
       String toolsRepository)
       throws InterruptedException, ActionConflictException {
     StarlarkRuleContext starlarkRuleContext = null;
-    try (Mutability mutability = Mutability.create("aspect")) {
+    // TODO(adonovan): simplify use of try/finally here.
+    try {
       AspectDescriptor aspectDescriptor =
           new AspectDescriptor(starlarkAspect.getAspectClass(), parameters);
       AnalysisEnvironment analysisEnv = ruleContext.getAnalysisEnvironment();
@@ -70,22 +71,19 @@
         ruleContext.ruleError(e.getMessage());
         return null;
       }
-      StarlarkThread thread =
-          StarlarkThread.builder(mutability)
-              .setSemantics(analysisEnv.getStarlarkSemantics())
-              .build();
-      thread.setPrintHandler(Event.makeDebugPrintHandler(analysisEnv.getEventHandler()));
+      try (Mutability mu = Mutability.create("aspect")) {
+        StarlarkThread thread = new StarlarkThread(mu, analysisEnv.getStarlarkSemantics());
+        thread.setPrintHandler(Event.makeDebugPrintHandler(analysisEnv.getEventHandler()));
 
-      new BazelStarlarkContext(
-              BazelStarlarkContext.Phase.ANALYSIS,
-              toolsRepository,
-              /*fragmentNameToClass=*/ null,
-              ruleContext.getRule().getPackage().getRepositoryMapping(),
-              ruleContext.getSymbolGenerator(),
-              ruleContext.getLabel())
-          .storeInThread(thread);
+        new BazelStarlarkContext(
+                BazelStarlarkContext.Phase.ANALYSIS,
+                toolsRepository,
+                /*fragmentNameToClass=*/ null,
+                ruleContext.getRule().getPackage().getRepositoryMapping(),
+                ruleContext.getSymbolGenerator(),
+                ruleContext.getLabel())
+            .storeInThread(thread);
 
-      try {
         Object aspectStarlarkObject =
             Starlark.call(
                 thread,
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/StarlarkImportLookupFunction.java b/src/main/java/com/google/devtools/build/lib/skyframe/StarlarkImportLookupFunction.java
index 4f20769..892cae7 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/StarlarkImportLookupFunction.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/StarlarkImportLookupFunction.java
@@ -677,24 +677,19 @@
       boolean inWorkspace,
       ImmutableMap<RepositoryName, RepositoryName> repositoryMapping)
       throws StarlarkImportFailedException, InterruptedException {
-    // .bzl predeclared environment
+    // set up .bzl predeclared environment
     Map<String, Object> predeclared = new HashMap<>(ruleClassProvider.getEnvironment());
     predeclared.put("native", packageFactory.getNativeModule(inWorkspace));
+    Module module = Module.withPredeclared(starlarkSemantics, predeclared);
+    module.setClientData(BazelModuleContext.create(moduleLabel, transitiveDigest));
 
     try (Mutability mu = Mutability.create("importing", moduleLabel)) {
-      StarlarkThread thread =
-          StarlarkThread.builder(mu)
-              .setGlobals(
-                  Module.createForBuiltins(predeclared)
-                      .withClientData(BazelModuleContext.create(moduleLabel, transitiveDigest)))
-              .setSemantics(starlarkSemantics)
-              .build();
+      StarlarkThread thread = new StarlarkThread(mu, starlarkSemantics);
       thread.setLoader(loadedModules::get);
       StoredEventHandler eventHandler = new StoredEventHandler();
       thread.setPrintHandler(Event.makeDebugPrintHandler(eventHandler));
       ruleClassProvider.setStarlarkThreadContext(thread, moduleLabel, repositoryMapping);
-      Module module = thread.getGlobals();
-      execAndExport(file, moduleLabel, eventHandler, thread);
+      execAndExport(file, moduleLabel, eventHandler, module, thread);
 
       Event.replayEventsOn(env.getListener(), eventHandler.getEvents());
       for (Postable post : eventHandler.getPosts()) {
@@ -711,7 +706,11 @@
   // Precondition: thread has a valid transitiveDigest.
   // TODO(adonovan): executeModule would make a better public API than this function.
   public static void execAndExport(
-      StarlarkFile file, Label extensionLabel, EventHandler handler, StarlarkThread thread)
+      StarlarkFile file,
+      Label extensionLabel,
+      EventHandler handler,
+      Module module,
+      StarlarkThread thread)
       throws InterruptedException {
 
     // Intercept execution after every assignment at top level
@@ -732,7 +731,7 @@
         });
 
     try {
-      EvalUtils.exec(file, thread.getGlobals(), thread);
+      EvalUtils.exec(file, module, thread);
     } catch (EvalException ex) {
       handler.handle(Event.error(ex.getLocation(), ex.getMessage()));
     }
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/testutils/TestUtils.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/testutils/TestUtils.java
index d662876..54221dc 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/testutils/TestUtils.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/testutils/TestUtils.java
@@ -96,30 +96,18 @@
     return TestUtils.roundTrip(value, ImmutableMap.of());
   }
 
-  public static void assertFramesEqual(Module frame1, Module frame2) {
-    assertThat(frame1.getTransitiveBindings())
-        .containsExactlyEntriesIn(frame2.getTransitiveBindings())
-        .inOrder();
-  }
-
   /**
    * Asserts that two {@link Module}s have the same structure. Needed because {@link Module} doesn't
    * override {@link Object#equals}.
    */
-  public static void assertModulesEqual(Module frame1, Module frame2) {
-    assertThat(frame1.mutability().getAnnotation())
-        .isEqualTo(frame2.mutability().getAnnotation());
-    assertThat(frame1.getClientData()).isEqualTo(frame2.getClientData());
-    assertThat(frame1.getTransitiveBindings())
-        .containsExactlyEntriesIn(frame2.getTransitiveBindings()).inOrder();
-    if (frame1.getParent() == null || frame2.getParent() == null) {
-      assertThat(frame1.getParent()).isNull();
-      assertThat(frame2.getParent()).isNull();
-    } else {
-      assertFramesEqual(frame1.getParent(), frame2.getParent());
-    }
-    assertThat(frame1.getExportedBindings())
-        .containsExactlyEntriesIn(frame2.getExportedBindings())
+  public static void assertModulesEqual(Module module1, Module module2) {
+    assertThat(module1.getClientData()).isEqualTo(module2.getClientData());
+    assertThat(module1.getGlobals()).containsExactlyEntriesIn(module2.getGlobals()).inOrder();
+    assertThat(module1.getExportedGlobals())
+        .containsExactlyEntriesIn(module2.getExportedGlobals())
+        .inOrder();
+    assertThat(module1.getPredeclaredBindings())
+        .containsExactlyEntriesIn(module2.getPredeclaredBindings())
         .inOrder();
   }
 
diff --git a/src/main/java/com/google/devtools/build/lib/starlarkdebug/server/DebugEventHelper.java b/src/main/java/com/google/devtools/build/lib/starlarkdebug/server/DebugEventHelper.java
index 312b6de..a1e5ffd 100644
--- a/src/main/java/com/google/devtools/build/lib/starlarkdebug/server/DebugEventHelper.java
+++ b/src/main/java/com/google/devtools/build/lib/starlarkdebug/server/DebugEventHelper.java
@@ -145,7 +145,7 @@
   private static ImmutableList<Scope> getScopes(ThreadObjectMap objectMap, Debug.Frame frame) {
     Map<String, Object> moduleVars =
         frame.getFunction() instanceof StarlarkFunction
-            ? ((StarlarkFunction) frame.getFunction()).getModule().getTransitiveBindings()
+            ? ((StarlarkFunction) frame.getFunction()).getModule().getGlobals()
             : ImmutableMap.of();
 
     ImmutableMap<String, Object> localVars = frame.getLocals();
diff --git a/src/main/java/com/google/devtools/build/lib/starlarkdebug/server/ThreadHandler.java b/src/main/java/com/google/devtools/build/lib/starlarkdebug/server/ThreadHandler.java
index c487a4d..d707aa0 100644
--- a/src/main/java/com/google/devtools/build/lib/starlarkdebug/server/ThreadHandler.java
+++ b/src/main/java/com/google/devtools/build/lib/starlarkdebug/server/ThreadHandler.java
@@ -28,6 +28,7 @@
 import com.google.devtools.build.lib.syntax.EvalUtils;
 import com.google.devtools.build.lib.syntax.FileOptions;
 import com.google.devtools.build.lib.syntax.Location;
+import com.google.devtools.build.lib.syntax.Module;
 import com.google.devtools.build.lib.syntax.ParserInput;
 import com.google.devtools.build.lib.syntax.Starlark;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
@@ -299,8 +300,12 @@
     try {
       servicingEvalRequest.set(true);
 
+      // TODO(adonovan): opt: don't parse and resolve the expression every time we hit a breakpoint
+      // (!).
       ParserInput input = ParserInput.create(content, "<debug eval>");
-      return EvalUtils.exec(input, FileOptions.DEFAULT, thread.getGlobals(), thread);
+      // TODO(adonovan): the module or call frame should be a parameter.
+      Module module = Module.ofInnermostEnclosingStarlarkFunction(thread);
+      return EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
     } finally {
       servicingEvalRequest.set(false);
     }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Eval.java b/src/main/java/com/google/devtools/build/lib/syntax/Eval.java
index ac1d809..0c3c3bf 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Eval.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Eval.java
@@ -63,7 +63,7 @@
           AssignmentStatement assign = (AssignmentStatement) stmt;
           for (Identifier id : Identifier.boundIdentifiers(assign.getLHS())) {
             String name = id.getName();
-            Object value = fn(fr).getModule().lookup(name);
+            Object value = fn(fr).getModule().getGlobal(name);
             fr.thread.postAssignHook.assign(name, value);
           }
         }
@@ -181,7 +181,7 @@
                   + "Make sure the 'load' statement appears in the global scope in your file",
               moduleName));
     }
-    Map<String, Object> globals = module.getExportedBindings();
+    Map<String, Object> globals = module.getExportedGlobals();
 
     for (LoadStatement.Binding binding : node.getBindings()) {
       // Extract symbol.
@@ -202,13 +202,9 @@
       // loads bind file-locally. Either way, the resolver should designate
       // the proper scope of binding.getLocalName() and this should become
       // simply assign(binding.getLocalName(), value).
-      // Currently, we update the module but not module.exportedBindings;
+      // Currently, we update the module but not module.exportedGlobals;
       // changing it to fr.locals.put breaks a test. TODO(adonovan): find out why.
-      try {
-        fn(fr).getModule().put(binding.getLocalName().getName(), value);
-      } catch (EvalException ex) {
-        throw new AssertionError(ex);
-      }
+      fn(fr).getModule().setGlobal(binding.getLocalName().getName(), value);
     }
   }
 
@@ -327,14 +323,10 @@
       case GLOBAL:
         // Updates a module binding and sets its 'exported' flag.
         // (Only load bindings are not exported.
-        // But exportedBindings does at run time what should be done in the resolver.)
+        // But exportedGlobals does at run time what should be done in the resolver.)
         Module module = fn(fr).getModule();
-        try {
-          module.put(name, value);
-          module.exportedBindings.add(name);
-        } catch (EvalException ex) {
-          throw new IllegalStateException(ex);
-        }
+        module.setGlobal(name, value);
+        module.exportedGlobals.add(name);
         break;
       default:
         throw new IllegalStateException(scope.toString());
@@ -659,10 +651,10 @@
         result = fr.locals.get(name);
         break;
       case GLOBAL:
-        result = fn(fr).getModule().lookup(name);
+        result = fn(fr).getModule().getGlobal(name);
         break;
       case PREDECLARED:
-        // TODO(laurentlb): look only at predeclared (not module globals).
+        // TODO(adonovan): call getPredeclared
         result = fn(fr).getModule().get(name);
         break;
       default:
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/FileLocations.java b/src/main/java/com/google/devtools/build/lib/syntax/FileLocations.java
index 44da0a4..0cc07d8 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/FileLocations.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/FileLocations.java
@@ -50,6 +50,10 @@
     return new FileLocations(computeLinestart(buffer), file, buffer.length);
   }
 
+  String file() {
+    return file;
+  }
+
   private int getLineAt(int offset) {
     if (offset < 0 || offset > size) {
       throw new IllegalStateException("Illegal position: " + offset);
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/FlagGuardedValue.java b/src/main/java/com/google/devtools/build/lib/syntax/FlagGuardedValue.java
index 2c7bfd2..3dd67a5 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/FlagGuardedValue.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/FlagGuardedValue.java
@@ -15,16 +15,16 @@
 package com.google.devtools.build.lib.syntax;
 
 /**
- * Wrapper on a value that controls its accessibility in Starlark based on the value of a
- * semantic flag.
+ * Wrapper on a value in the predeclared lexical block that controls its accessibility to Starlark
+ * based on the value of a semantic flag.
  *
- * <p>For example, this could control whether symbol "Foo" exists in the Starlark
- * global frame: such a symbol might only be accessible if --experimental_foo is set to true.
- * In order to create this control, an instance of this class should be added to the global
- * frame under "Foo". This flag guard will throw a descriptive {@link EvalException} when
- * "Foo" would be accessed without the proper flag.
+ * <p>For example, this could control whether symbol "Foo" exists in the Starlark global frame: such
+ * a symbol might only be accessible if --experimental_foo is set to true. In order to create this
+ * control, an instance of this class should be added to the global frame under "Foo". This flag
+ * guard will throw a descriptive {@link EvalException} when "Foo" would be accessed without the
+ * proper flag.
  */
-public class FlagGuardedValue {
+public final class FlagGuardedValue {
   private final Object obj;
   private final String flag;
   private final FlagType flagType;
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Module.java b/src/main/java/com/google/devtools/build/lib/syntax/Module.java
index 703d6fd..f1f97be 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Module.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Module.java
@@ -16,6 +16,13 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableMap;
+import com.google.devtools.build.lib.skyframe.serialization.DeserializationContext;
+import com.google.devtools.build.lib.skyframe.serialization.ObjectCodec;
+import com.google.devtools.build.lib.skyframe.serialization.SerializationContext;
+import com.google.devtools.build.lib.skyframe.serialization.SerializationException;
+import com.google.protobuf.CodedInputStream;
+import com.google.protobuf.CodedOutputStream;
+import java.io.IOException;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
@@ -32,59 +39,56 @@
  * Bazel, the predeclared environment of the module for a BUILD or .bzl file defines name values
  * such as cc_binary and glob.
  *
- * <p>The predeclared environment currently must include the "universal" names present in every
- * Starlark thread in every dialect, such as None, len, and str.
+ * <p>The predeclared environment implicitly includes the "universal" names present in every
+ * Starlark thread in every dialect, such as None, len, and str; see {@link Starlark#UNIVERSE}.
  *
- * <p>Global bindings in a Module may shadow bindings inherited from the predeclared or universe
- * block.
+ * <p>Global bindings in a Module may shadow bindings inherited from the predeclared block.
  *
- * <p>A module may carry an arbitrary piece of metadata called its "label". In Bazel, for example,
- * the label is a build label such as "//dir:file.bzl", for use by the Label function. This is a
- * hack.
+ * <p>A module may carry an arbitrary piece of client data. In Bazel, for example, the client data
+ * records the module's build label (such as "//dir:file.bzl").
  *
- * <p>A {@link Module} may be constructed in a two-phase process. To do this, call the nullary
- * constructor to create an uninitialized {@link Module}, then call {@link #initialize}. It is
- * illegal to use any other method in-between these two calls, or to call {@link #initialize} on an
- * already initialized {@link Module}.
+ * <p>Use {@link #create} to create a {@link Module} with no predeclared bindings other than the
+ * universal ones. Use {@link #withPredeclared(StarlarkSemantics, Map)} to create a module with the
+ * predeclared environment specified by the map, using the semantics to determine whether any
+ * FlagGuardedValues in the map are enabled or disabled.
  */
-// TODO(adonovan):
-// - make fields private where possible.
-// - remove references to this from StarlarkThread.
-// - separate the universal predeclared environment and make it implicit.
-// - eliminate initialize(). The only constructor we need is:
-//   (String name, Mutability mu, Map<String, Object> predeclared, Object label).
 public final class Module implements Resolver.Module {
 
-  /**
-   * Final, except that it may be initialized after instantiation. Null mutability indicates that
-   * this Frame is uninitialized.
-   */
-  @Nullable private Mutability mutability;
+  // The module's predeclared environment. Excludes UNIVERSE bindings.
+  private ImmutableMap<String, Object> predeclared;
 
-  /** Final, except that it may be initialized after instantiation. */
-  @Nullable private Module universe;
+  // The module's global bindings, in order of creation.
+  private final LinkedHashMap<String, Object> globals = new LinkedHashMap<>();
 
-  /**
-   * Optional piece of metadata associated with the file. If present, must return a representative
-   * label of the module from {@link Object#toString()}
-   */
+  // Names of globals that are exported and can be loaded from other modules.
+  // TODO(adonovan): eliminate this field when the resolver does its job properly.
+  final HashSet<String> exportedGlobals = new HashSet<>();
+
+  // An optional piece of metadata associated with the module/file.
+  // May be set after construction (too obscure to burden the constructors).
+  // Its toString appears to Starlark in str(function): "<function f from ...>".
   @Nullable private Object clientData;
 
-  /** Bindings are maintained in order of creation. */
-  private final LinkedHashMap<String, Object> bindings;
+  private Module(ImmutableMap<String, Object> predeclared) {
+    this.predeclared = predeclared;
+  }
 
   /**
-   * A list of bindings which *would* exist in this global frame under certain semantic flags, but
-   * do not exist using the semantic flags used in this frame's creation. This map should not be
-   * used for lookups; it should only be used to throw descriptive error messages when a lookup of a
-   * restricted object is attempted.
+   * Constructs a Module with the specified predeclared bindings, filtered by the semantics, in
+   * addition to the standard environment, {@link Starlark#UNIVERSE}.
    */
-  private final LinkedHashMap<String, FlagGuardedValue> restrictedBindings;
+  public static Module withPredeclared(
+      StarlarkSemantics semantics, Map<String, Object> predeclared) {
+    return new Module(filter(predeclared, semantics));
+  }
 
-  /** Set of bindings that are exported (can be loaded from other modules). */
-  // Public for use in serialization tests, which live in a different package for Bazelish reasons.
-  public final Set<String> exportedBindings;
-
+  /**
+   * Creates a module with no predeclared bindings other than the standard environment, {@link
+   * Starlark#UNIVERSE}.
+   */
+  public static Module create() {
+    return new Module(/*predeclared=*/ ImmutableMap.of());
+  }
 
   /**
    * Returns the module (file) of the innermost enclosing Starlark function on the call stack, or
@@ -102,180 +106,71 @@
     return null;
   }
 
-  /** Constructs an uninitialized instance; caller must call {@link #initialize} before use. */
-  public Module() {
-    this.mutability = null;
-    this.universe = null;
-    this.clientData = null;
-    this.bindings = new LinkedHashMap<>();
-    this.restrictedBindings = new LinkedHashMap<>();
-    this.exportedBindings = new HashSet<>();
-  }
-
-  private Module(
-      Mutability mutability,
-      @Nullable Module universe,
-      @Nullable Object clientData,
-      @Nullable Map<String, Object> bindings,
-      @Nullable Map<String, FlagGuardedValue> restrictedBindings) {
-    Preconditions.checkState(universe == null || universe.universe == null);
-    this.mutability = Preconditions.checkNotNull(mutability);
-    this.universe = universe;
-    if (clientData != null) {
-      this.clientData = clientData;
-    } else if (universe != null) {
-      this.clientData = universe.clientData;
-    } else {
-      this.clientData = null;
-    }
-    this.bindings = new LinkedHashMap<>();
-    if (bindings != null) {
-      this.bindings.putAll(bindings);
-    }
-    this.restrictedBindings = new LinkedHashMap<>();
-    if (restrictedBindings != null) {
-      this.restrictedBindings.putAll(restrictedBindings);
-    }
-    if (universe != null) {
-      this.restrictedBindings.putAll(universe.restrictedBindings);
-    }
-    this.exportedBindings = new HashSet<>();
-  }
-
-  public Module(Mutability mutability) {
-    this(mutability, null, null, null, null);
-  }
-
-  public Module(Mutability mutability, @Nullable Module universe) {
-    this(mutability, universe, null, null, null);
-  }
-
-  public Module(Mutability mutability, @Nullable Module universe, @Nullable Object clientData) {
-    this(mutability, universe, clientData, null, null);
-  }
-
-  /** Constructs a global frame for the given builtin bindings. */
-  public static Module createForBuiltins(Map<String, Object> bindings) {
-    Mutability mutability = Mutability.create("<builtins>").freeze();
-    return new Module(mutability, null, null, bindings, null);
-  }
-
   /**
-   * Constructs a global frame based on the given parent frame, filtering out flag-restricted global
-   * objects.
+   * Returns a map in which each semantics-enabled FlagGuardedValue has been replaced by the value
+   * it guards. Disabled FlagGuardedValues are left in place, and should be treated as unavailable.
+   * The iteration order is unchanged.
    */
-  static Module filterOutRestrictedBindings(
-      Mutability mutability, Module parent, StarlarkSemantics semantics) {
-    if (parent == null) {
-      return new Module(mutability);
-    }
-    Preconditions.checkArgument(parent.mutability().isFrozen(), "parent frame must be frozen");
-    Preconditions.checkArgument(parent.universe == null);
-
-    Map<String, Object> filteredBindings = new LinkedHashMap<>();
-    Map<String, FlagGuardedValue> restrictedBindings = new LinkedHashMap<>();
-
-    for (Map.Entry<String, Object> binding : parent.bindings.entrySet()) {
-      if (binding.getValue() instanceof FlagGuardedValue) {
-        FlagGuardedValue val = (FlagGuardedValue) binding.getValue();
-        if (val.isObjectAccessibleUsingSemantics(semantics)) {
-          filteredBindings.put(binding.getKey(), val.getObject());
-        } else {
-          restrictedBindings.put(binding.getKey(), val);
+  private static ImmutableMap<String, Object> filter(
+      Map<String, Object> predeclared, StarlarkSemantics semantics) {
+    ImmutableMap.Builder<String, Object> filtered = ImmutableMap.builder();
+    for (Map.Entry<String, Object> bind : predeclared.entrySet()) {
+      Object v = bind.getValue();
+      if (v instanceof FlagGuardedValue) {
+        FlagGuardedValue fv = (FlagGuardedValue) bind.getValue();
+        if (fv.isObjectAccessibleUsingSemantics(semantics)) {
+          v = fv.getObject();
         }
-      } else {
-        filteredBindings.put(binding.getKey(), binding.getValue());
       }
+      filtered.put(bind.getKey(), v);
     }
-
-    restrictedBindings.putAll(parent.restrictedBindings);
-
-    return new Module(
-        mutability, /*universe=*/ null, parent.clientData, filteredBindings, restrictedBindings);
-  }
-
-  private void checkInitialized() {
-    Preconditions.checkNotNull(mutability, "Attempted to use Frame before initializing it");
-  }
-
-  public void initialize(
-      Mutability mutability,
-      @Nullable Module universe,
-      @Nullable Object clientData,
-      Map<String, Object> bindings) {
-    Preconditions.checkState(
-        universe == null || universe.universe == null); // no more than 1 universe
-    Preconditions.checkState(
-        this.mutability == null, "Attempted to initialize an already initialized Frame");
-    this.mutability = Preconditions.checkNotNull(mutability);
-    this.universe = universe;
-    if (clientData != null) {
-      this.clientData = clientData;
-    } else if (universe != null) {
-      this.clientData = universe.clientData;
-    } else {
-      this.clientData = null;
-    }
-    this.bindings.putAll(bindings);
+    return filtered.build();
   }
 
   /**
-   * Returns a new {@link Module} with the same fields, except that {@link #clientData} is set to
-   * the given value. This allows associating custom data with a Module object.
-   *
-   * <p>Please note that if the {@link #clientData} is present, {@code clientData.toString()} will
-   * be included in the result of {@code str(fn)} for each {@link StarlarkFunction} defined within
-   * the module. This means that the custom data we are storing must implement {@link
-   * Object#toString()} which makes its value useful as a module label since it will be presented to
-   * the end-user in that context. See {@link StarlarkFunction#repr(Printer)} for details.
+   * Sets the client data (an arbitrary application-specific value) associated with the module. It
+   * may be retrieved using {@link #getClientData}. Its {@code toString} form appears in the result
+   * of {@code str(fn)} where {@code fn} is a StarlarkFunction: "<function f from ...>".
    */
-  public Module withClientData(Object clientData) {
-    checkInitialized();
-    return new Module(
-        mutability, /*universe=*/ null, clientData, bindings, /*restrictedBindings=*/ null);
-  }
-
-  /** Returns the {@link Mutability} of this {@link Module}. */
-  public Mutability mutability() {
-    checkInitialized();
-    return mutability;
+  public void setClientData(@Nullable Object clientData) {
+    this.clientData = clientData;
   }
 
   /**
-   * Returns the parent {@link Module}, if it exists.
-   *
-   * <p>TODO(laurentlb): Should be called getUniverse.
-   */
-  @Nullable
-  public Module getParent() {
-    checkInitialized();
-    return universe;
-  }
-
-  /**
-   * Returns the client data associated with this {@code Module}.
-   *
-   * <p>{@code getClientData().toString()} should provide a valid label for the module. Please see
-   * the documentation of {@link #withClientData(Object)} for more details.
+   * Returns the client data associated with this module by a prior call to {@link #setClientData}.
    */
   @Nullable
   public Object getClientData() {
-    checkInitialized();
     return clientData;
   }
 
+  /** Returns the value of a predeclared (or universal) binding in this module. */
+  Object getPredeclared(String name) {
+    Object v = predeclared.get(name);
+    if (v != null) {
+      return v;
+    }
+    return Starlark.UNIVERSE.get(name);
+  }
+
   /**
-   * Returns a map of direct bindings of this {@link Module}, ignoring universe.
+   * Returns this module's additional predeclared bindings. (Excludes {@link Starlark#UNIVERSE}.)
+   *
+   * <p>The map reflects any semantics-based filtering of FlagGuardedValues done by {@link
+   * #withPredeclared}: enabled FlagGuardedValues are replaced by their underlying value.
+   */
+  public ImmutableMap<String, Object> getPredeclaredBindings() {
+    return predeclared;
+  }
+
+  /**
+   * Returns a read-only view of this module's global bindings.
    *
    * <p>The bindings are returned in a deterministic order (for a given sequence of initial values
    * and updates).
-   *
-   * <p>For efficiency an unmodifiable view is returned. Callers should assume that the view is
-   * invalidated by any subsequent modification to the {@link Module}'s bindings.
    */
-  public Map<String, Object> getBindings() {
-    checkInitialized();
-    return Collections.unmodifiableMap(bindings);
+  public Map<String, Object> getGlobals() {
+    return Collections.unmodifiableMap(globals);
   }
 
   /**
@@ -283,87 +178,141 @@
    * `load`).
    */
   // TODO(adonovan): whether bindings are exported should be decided by the resolver;
-  // non-exported bindings should never be added to the module.
-  public Map<String, Object> getExportedBindings() {
-    checkInitialized();
+  //  non-exported bindings should never be added to the module.  Delete this.
+  public ImmutableMap<String, Object> getExportedGlobals() {
     ImmutableMap.Builder<String, Object> result = new ImmutableMap.Builder<>();
-    for (Map.Entry<String, Object> entry : bindings.entrySet()) {
-      if (exportedBindings.contains(entry.getKey())) {
+    for (Map.Entry<String, Object> entry : globals.entrySet()) {
+      if (exportedGlobals.contains(entry.getKey())) {
         result.put(entry);
       }
     }
     return result.build();
   }
 
+  /** Implements the resolver's module interface. */
   @Override
   public Set<String> getNames() {
-    return getTransitiveBindings().keySet();
+    // TODO(adonovan): for now, the resolver treats all predeclared/universe
+    //  and global names as one bucket (Scope.PREDECLARED). Fix that.
+    // TODO(adonovan): opt: change the resolver to request names on
+    //  demand to avoid all this set copying.
+    HashSet<String> names = new HashSet<>();
+    for (Map.Entry<String, Object> bind : getTransitiveBindings().entrySet()) {
+      if (bind.getValue() instanceof FlagGuardedValue) {
+        continue; // disabled
+      }
+      names.add(bind.getKey());
+    }
+    return names;
   }
 
   @Override
   public String getUndeclaredNameError(String name) {
-    FlagGuardedValue v = restrictedBindings.get(name);
-    return v == null ? null : v.getErrorFromAttemptingAccess(name);
-  }
-
-  /** Returns an environment containing both module and predeclared bindings. */
-  // TODO(adonovan): eliminate in favor of explicit module vs. predeclared operations.
-  public Map<String, Object> getTransitiveBindings() {
-    checkInitialized();
-    // Can't use ImmutableMap.Builder because it doesn't allow duplicates.
-    LinkedHashMap<String, Object> collectedBindings = new LinkedHashMap<>();
-    if (universe != null) {
-      collectedBindings.putAll(universe.getTransitiveBindings());
-    }
-    collectedBindings.putAll(getBindings());
-    return collectedBindings;
+    Object v = getPredeclared(name);
+    return v instanceof FlagGuardedValue
+        ? ((FlagGuardedValue) v).getErrorFromAttemptingAccess(name)
+        : null;
   }
 
   /**
-   * Returns the value of the specified module variable, or null if not bound. Does not look in the
+   * Returns a new map containing the predeclared (including universal) and global bindings of this
+   * module.
+   */
+  // TODO(adonovan): eliminate; clients should explicitly choose getPredeclared or getGlobals.
+  public Map<String, Object> getTransitiveBindings() {
+    // Can't use ImmutableMap.Builder because it doesn't allow duplicates.
+    LinkedHashMap<String, Object> env = new LinkedHashMap<>();
+    env.putAll(Starlark.UNIVERSE);
+    env.putAll(predeclared);
+    env.putAll(globals);
+    return env;
+  }
+
+  /**
+   * Returns the value of the specified global variable, or null if not bound. Does not look in the
    * predeclared environment.
    */
-  public Object lookup(String varname) {
-    checkInitialized();
-    return bindings.get(varname);
+  public Object getGlobal(String name) {
+    return globals.get(name);
   }
 
   /**
-   * Returns the value of the named variable in the module environment, or if not bound there, in
-   * the predeclared environment, or if not bound there, null.
+   * Returns the value of the named variable in the module global environment (as if by {@link
+   * #getGlobal}), or if not bound there, in the predeclared environment (as if by {@link
+   * #getPredeclared}, or if not bound there, null.
    */
-  public Object get(String varname) {
+  public Object get(String name) {
     // TODO(adonovan): delete this whole function, and getTransitiveBindings.
     // With proper resolution, the interpreter will know whether
     // to look in the module or the predeclared/universal environment.
-    checkInitialized();
-    Object val = bindings.get(varname);
-    if (val != null) {
-      return val;
+    Object v = getGlobal(name);
+    if (v != null) {
+      return v;
     }
-    if (universe != null) {
-      return universe.get(varname);
-    }
-    return null;
+    return getPredeclared(name);
   }
 
-  /** Updates a binding in the module environment. */
-  public void put(String varname, Object value) throws EvalException {
-    Preconditions.checkNotNull(value, "Module.put(%s, null)", varname);
-    checkInitialized();
-    if (mutability.isFrozen()) {
-      throw Starlark.errorf("trying to mutate a frozen module");
-    }
-    bindings.put(varname, value);
+  /** Updates a global binding in the module environment. */
+  public void setGlobal(String name, Object value) {
+    Preconditions.checkNotNull(value, "Module.setGlobal(%s, null)", name);
+    globals.put(name, value);
   }
 
   @Override
   public String toString() {
-    // TODO(adonovan): use the file name of the module (not visible to Starlark programs).
-    if (mutability == null) {
-      return "<Uninitialized Module>";
-    } else {
-      return String.format("<Module%s>", mutability());
+    return String.format("<module %s>", clientData == null ? "?" : clientData);
+  }
+
+  static final class ModuleCodec implements ObjectCodec<Module> {
+    @Override
+    public Class<Module> getEncodedClass() {
+      return Module.class;
+    }
+
+    @Override
+    public MemoizationStrategy getStrategy() {
+      return MemoizationStrategy.MEMOIZE_BEFORE;
+    }
+
+    @Override
+    public void serialize(SerializationContext context, Module module, CodedOutputStream codedOut)
+        throws SerializationException, IOException {
+      context.serialize(module.predeclared, codedOut);
+      context.serialize(module.globals, codedOut);
+      context.serialize(module.exportedGlobals, codedOut);
+      context.serialize(module.clientData, codedOut);
+    }
+
+    @Override
+    public Module deserialize(DeserializationContext context, CodedInputStream codedIn)
+        throws SerializationException, IOException {
+      // Modules and their globals form cycles in the object graph,
+      // so we must register the empty Module object before reading
+      // and populating its elements.
+      //
+      // The MEMOIZE_BEFORE machinery seems to require that registerInitialValue
+      // be called even before we deserialize the predeclared map, even though it
+      // is not cyclic. This seems like a bug.
+      //
+      // [brandjon notes: "I'll bet it's because Deserializer#tagForMemoizedBefore is getting
+      // clobbered by any deserialization that occurs between the beginning of this function call
+      // and the registerInitialValue call. This bit of extra statefulness is probably to avoid
+      // threading it through the serialization signature, given that it's not used in all
+      // codepaths."]
+      Module module = create();
+      context.registerInitialValue(module);
+
+      ImmutableMap<String, Object> predeclared = context.deserialize(codedIn);
+      Map<String, Object> globals = context.deserialize(codedIn);
+      Set<String> exportedGlobals = context.deserialize(codedIn);
+      Object clientData = context.deserialize(codedIn);
+
+      module.predeclared = predeclared;
+      module.globals.putAll(globals);
+      module.exportedGlobals.addAll(exportedGlobals);
+      module.setClientData(clientData);
+
+      return module;
     }
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/ParamDescriptor.java b/src/main/java/com/google/devtools/build/lib/syntax/ParamDescriptor.java
index ca0447c..81244ea 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/ParamDescriptor.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/ParamDescriptor.java
@@ -146,7 +146,14 @@
 
   // Evaluates the default value expression for a parameter.
   private static Object evalDefault(String name, String expr) {
-    // Common cases; also needed for bootstrapping UNIVERSE.
+    // Values required by defaults of functions in UNIVERSE must
+    // be handled without depending on the evaluator, or even
+    // on defaultValueCache, because JVM global variable initialization
+    // is such a mess. (Specifically, it's completely dynamic,
+    // so if two or more variables are mutually dependent, like
+    // defaultValueCache and UNIVERSE would be, you have to write
+    // code that works in all possible dynamic initialization orders.)
+    // Better not to go there.
     if (expr.equals("None")) {
       return Starlark.NONE;
     } else if (expr.equals("True")) {
@@ -155,20 +162,32 @@
       return false;
     } else if (expr.equals("unbound")) {
       return Starlark.UNBOUND;
+    } else if (expr.equals("0")) {
+      return 0;
+    } else if (expr.equals("1")) {
+      return 1;
+    } else if (expr.equals("[]")) {
+      return StarlarkList.empty();
+    } else if (expr.equals("()")) {
+      return Tuple.empty();
+    } else if (expr.equals("\" \"")) {
+      return " ";
     }
 
     Object x = defaultValueCache.get(expr);
     if (x != null) {
       return x;
     }
-    try (Mutability mutability = Mutability.create("initialization")) {
+
+    // We can't evaluate Starlark code until UNIVERSE is bootstrapped.
+    if (Starlark.UNIVERSE == null) {
+      throw new IllegalStateException("no bootstrap value for " + name + "=" + expr);
+    }
+
+    Module module = Module.create();
+    try (Mutability mu = Mutability.create("Builtin param default init")) {
       // Note that this Starlark thread ignores command line flags.
-      StarlarkThread thread =
-          StarlarkThread.builder(mutability)
-              .useDefaultSemantics()
-              .setGlobals(Module.createForBuiltins(Starlark.UNIVERSE))
-              .build();
-      Module module = thread.getGlobals();
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
 
       // Disable polling of the java.lang.Thread.interrupt flag during
       // Starlark evaluation. Assuming the expression does not call a
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/StarlarkFile.java b/src/main/java/com/google/devtools/build/lib/syntax/StarlarkFile.java
index ea4b675..55cc3b9 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/StarlarkFile.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/StarlarkFile.java
@@ -154,6 +154,11 @@
     return options;
   }
 
+  /** Returns the name of this file, as specified to the parser. */
+  public String getName() {
+    return locs.file();
+  }
+
   /** A ParseProfiler records the start and end times of parse operations. */
   public interface ParseProfiler {
     Object start(String filename);
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/StarlarkFunction.java b/src/main/java/com/google/devtools/build/lib/syntax/StarlarkFunction.java
index b0bafb2..be1f20a 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/StarlarkFunction.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/StarlarkFunction.java
@@ -145,6 +145,7 @@
 
   @Override
   public void repr(Printer printer) {
+    // TODO(adonovan): use the file name instead. But that's a breaking Bazel change.
     Object clientData = module.getClientData();
 
     printer.append("<function " + getName());
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/StarlarkThread.java b/src/main/java/com/google/devtools/build/lib/syntax/StarlarkThread.java
index 3099276..5320e0f 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/StarlarkThread.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/StarlarkThread.java
@@ -20,9 +20,7 @@
 import com.google.common.collect.Maps;
 import java.util.ArrayList;
 import java.util.HashMap;
-import java.util.LinkedHashSet;
 import java.util.Map;
-import java.util.Set;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Predicate;
 import javax.annotation.Nullable;
@@ -44,17 +42,10 @@
  * interacting {@code StarlarkThread}s. It is a Starlark-level error to attempt to mutate a frozen
  * {@code StarlarkThread} or its objects, but it is a Java-level error to attempt to mutate an
  * unfrozen {@code StarlarkThread} or its objects from within a different {@code StarlarkThread}.
- *
- * <p>One creates an StarlarkThread using the {@link #builder} function, before evaluating code in
- * it with {@link StarlarkFile#eval}, or with {@link StarlarkFile#exec} (where the AST was obtained
- * by passing a {@link Resolver} constructed from the StarlarkThread to {@link StarlarkFile#parse}.
- * When the computation is over, the frozen StarlarkThread can still be queried with {@link
- * #lookup}.
  */
 public final class StarlarkThread {
 
-  // The mutability of the StarlarkThread comes from its initial module.
-  // TODO(adonovan): not every thread initializes a module.
+  /** The mutability of values created by this thread. */
   private final Mutability mutability;
 
   // profiler state
@@ -166,21 +157,6 @@
     }
   }
 
-  // The module initialized by this Starlark thread.
-  //
-  // TODO(adonovan): eliminate. First we need to simplify the set-up sequence like so:
-  //
-  //    // Filter predeclaredEnv based on semantics,
-  //    // create a mutability, and retain the semantics:
-  //    Module module = new Module(semantics, predeclaredEnv);
-  //
-  //    // Create a thread that takes its semantics and mutability
-  //    // (and only them) from the Module.
-  //    StarlarkThread thread = StarlarkThread.toInitializeModule(module);
-  //
-  // Then clients that call thread.getGlobals() should use 'module' directly.
-  private final Module module;
-
   /** The semantics options that affect how Starlark code is evaluated. */
   private final StarlarkSemantics semantics;
 
@@ -269,19 +245,11 @@
     }
   }
 
+  /** Returns the mutability for values created by this thread. */
   public Mutability mutability() {
     return mutability;
   }
 
-  /** Returns the module initialized by this StarlarkThread. */
-  // TODO(adonovan): get rid of this. Logically, a thread doesn't have module, but every
-  // Starlark source function does. If you want to know the module of the innermost
-  // enclosing call from a function defined in Starlark source code, use
-  // Module.ofInnermostEnclosingStarlarkFunction.
-  public Module getGlobals() {
-    return module;
-  }
-
   /**
    * A PrintHandler determines how a Starlark thread deals with print statements. It is invoked by
    * the built-in {@code print} function. Its default behavior is to write the message to standard
@@ -369,84 +337,16 @@
   /**
    * Constructs a StarlarkThread.
    *
-   * @param module the module initialized by this StarlarkThread
+   * @param mu the (non-frozen) mutability of values created by this thread.
    * @param semantics the StarlarkSemantics for this thread.
    */
-  private StarlarkThread(Module module, StarlarkSemantics semantics) {
-    this.module = Preconditions.checkNotNull(module);
-    this.mutability = module.mutability();
-    Preconditions.checkArgument(!module.mutability().isFrozen());
+  public StarlarkThread(Mutability mu, StarlarkSemantics semantics) {
+    Preconditions.checkArgument(!mu.isFrozen());
+    this.mutability = mu;
     this.semantics = semantics;
   }
 
   /**
-   * A Builder class for StarlarkThread.
-   *
-   * <p>The caller must explicitly set the semantics by calling either {@link #setSemantics} or
-   * {@link #useDefaultSemantics}.
-   */
-  // TODO(adonovan): Decouple Module from thread, and eliminate the builder.
-  // Expose a public constructor from (mutability, semantics).
-  public static class Builder {
-    private final Mutability mutability;
-    @Nullable private Module predeclared;
-    @Nullable private StarlarkSemantics semantics;
-
-    Builder(Mutability mutability) {
-      this.mutability = mutability;
-    }
-
-    /**
-     * Set the predeclared environment of the module created for this thread.
-     *
-     * <p>Any values in {@code predeclared.getBindings()} that are FlagGuardedValues will be
-     * filtered according to the thread's semantics.
-     */
-    // TODO(adonovan): remove this. A thread should not have a Module.
-    // It's only here to piggyback off the semantics for filtering.
-    // (The name is also wrong: really it should be setPredeclared.)
-    // And it's always called with value from Module.createForBuiltins.
-    // Instead, just expose a constructor like Module.withPredeclared(semantics, predeclared).
-    // A Module builder is not a crazy idea.
-    public Builder setGlobals(Module predeclared) {
-      Preconditions.checkState(this.predeclared == null);
-      this.predeclared = predeclared;
-      return this;
-    }
-
-    public Builder setSemantics(StarlarkSemantics semantics) {
-      this.semantics = semantics;
-      return this;
-    }
-
-    public Builder useDefaultSemantics() {
-      this.semantics = StarlarkSemantics.DEFAULT;
-      return this;
-    }
-
-    /** Builds the StarlarkThread. */
-    public StarlarkThread build() {
-      Preconditions.checkArgument(!mutability.isFrozen());
-      if (semantics == null) {
-        throw new IllegalArgumentException("must call either setSemantics or useDefaultSemantics");
-      }
-      // Filter out restricted objects from the universe scope. This cannot be done in-place in
-      // creation of the input global universe scope, because this environment's semantics may not
-      // have been available during its creation. Thus, create a new universe scope for this
-      // environment which is equivalent in every way except that restricted bindings are
-      // filtered out.
-      predeclared = Module.filterOutRestrictedBindings(mutability, predeclared, semantics);
-
-      Module module = new Module(mutability, predeclared);
-      return new StarlarkThread(module, semantics);
-    }
-  }
-
-  public static Builder builder(Mutability mutability) {
-    return new Builder(mutability);
-  }
-
-  /**
    * Specifies a hook function to be run after each assignment at top level.
    *
    * <p>This is a short-term hack to allow us to consolidate all StarlarkFile execution in one place
@@ -468,20 +368,6 @@
     return semantics;
   }
 
-  /**
-   * Returns a set of all names of variables that are accessible in this {@code StarlarkThread}, in
-   * a deterministic order.
-   */
-  // TODO(adonovan): eliminate this once we do resolution.
-  Set<String> getVariableNames() {
-    LinkedHashSet<String> vars = new LinkedHashSet<>();
-    if (!callstack.isEmpty()) {
-      vars.addAll(frame(0).locals.keySet());
-    }
-    vars.addAll(module.getTransitiveBindings().keySet());
-    return vars;
-  }
-
   // Implementation of Debug.getCallStack.
   // Intentionally obscured to steer most users to the simpler getCallStack.
   ImmutableList<Debug.Frame> getDebugCallStack() {
@@ -593,7 +479,7 @@
 
   @Override
   public String toString() {
-    return String.format("<StarlarkThread%s>", mutability());
+    return String.format("<StarlarkThread%s>", mutability);
   }
 
   /** CallProfiler records the start and end wall times of function calls. */
diff --git a/src/main/java/com/google/devtools/build/skydoc/SkydocMain.java b/src/main/java/com/google/devtools/build/skydoc/SkydocMain.java
index 490d500..266b473 100644
--- a/src/main/java/com/google/devtools/build/skydoc/SkydocMain.java
+++ b/src/main/java/com/google/devtools/build/skydoc/SkydocMain.java
@@ -166,7 +166,7 @@
 
   private final EventHandler eventHandler = new SystemOutEventHandler();
   private final LinkedHashSet<Path> pending = new LinkedHashSet<>();
-  private final Map<Path, StarlarkThread> loaded = new HashMap<>();
+  private final Map<Path, Module> loaded = new HashMap<>();
   private final StarlarkFileAccessor fileAccessor;
   private final List<String> depRoots;
   private final String workspaceName;
@@ -301,7 +301,7 @@
    *     no docstring, an empty string will be printed.
    * @throws InterruptedException if evaluation is interrupted
    */
-  public StarlarkThread eval(
+  public Module eval(
       StarlarkSemantics semantics,
       Label label,
       ImmutableMap.Builder<String, RuleInfo> ruleInfoMap,
@@ -318,7 +318,7 @@
 
     List<AspectInfoWrapper> aspectInfoList = new ArrayList<>();
 
-    StarlarkThread thread =
+    Module module =
         recursiveEval(
             semantics, label, ruleInfoList, providerInfoList, aspectInfoList, moduleDocMap);
 
@@ -336,9 +336,8 @@
             .collect(
                 Collectors.toMap(AspectInfoWrapper::getIdentifierFunction, Functions.identity()));
 
-    // Sort the bindings so their ordering is deterministic.
-    TreeMap<String, Object> sortedBindings =
-        new TreeMap<>(thread.getGlobals().getExportedBindings());
+    // Sort the globals bindings by name.
+    TreeMap<String, Object> sortedBindings = new TreeMap<>(module.getExportedGlobals());
 
     for (Entry<String, Object> envEntry : sortedBindings.entrySet()) {
       if (ruleFunctions.containsKey(envEntry.getValue())) {
@@ -369,7 +368,7 @@
       }
     }
 
-    return thread;
+    return module;
   }
 
   /**
@@ -421,7 +420,7 @@
    *     method will add to this list as it evaluates additional files
    * @throws InterruptedException if evaluation is interrupted
    */
-  private StarlarkThread recursiveEval(
+  private Module recursiveEval(
       StarlarkSemantics semantics,
       Label label,
       List<RuleInfoWrapper> ruleInfoList,
@@ -451,7 +450,7 @@
         String module = load.getImport().getValue();
         Label relativeLabel = label.getRelativeWithRemapping(module, ImmutableMap.of());
         try {
-          StarlarkThread importThread =
+          Module loadedModule =
               recursiveEval(
                   semantics,
                   relativeLabel,
@@ -459,7 +458,7 @@
                   providerInfoList,
                   aspectInfoList,
                   moduleDocMap);
-          imports.put(module, importThread.getGlobals());
+          imports.put(module, loadedModule);
         } catch (NoSuchFileException noSuchFileException) {
           throw new StarlarkEvaluationException(
               String.format(
@@ -470,13 +469,12 @@
       }
     }
 
-    StarlarkThread thread =
+    Module module =
         evalSkylarkBody(semantics, file, imports, ruleInfoList, providerInfoList, aspectInfoList);
 
     pending.remove(path);
-    thread.mutability().freeze();
-    loaded.put(path, thread);
-    return thread;
+    loaded.put(path, module);
+    return module;
   }
 
   private Path pathOfLabel(Label label, StarlarkSemantics semantics) {
@@ -501,7 +499,7 @@
   }
 
   /** Evaluates the AST from a single Starlark file, given the already-resolved imports. */
-  private StarlarkThread evalSkylarkBody(
+  private static Module evalSkylarkBody(
       StarlarkSemantics semantics,
       StarlarkFile file,
       Map<String, Module> imports,
@@ -510,39 +508,38 @@
       List<AspectInfoWrapper> aspectInfoList)
       throws InterruptedException, StarlarkEvaluationException {
 
-    StarlarkThread thread =
-        createStarlarkThread(
-            semantics,
-            globalFrame(ruleInfoList, providerInfoList, aspectInfoList),
-            imports);
-    Module module = thread.getGlobals();
+    Module module =
+        Module.withPredeclared(
+            semantics, getPredeclaredEnvironment(ruleInfoList, providerInfoList, aspectInfoList));
 
     Resolver.resolveFile(file, module);
     if (!file.ok()) {
       throw new StarlarkEvaluationException(file.errors().get(0).toString());
     }
 
-    try {
+    // execute
+    try (Mutability mu = Mutability.create("Skydoc")) {
+      StarlarkThread thread = new StarlarkThread(mu, semantics);
+      // We use the default print handler, which writes to stderr.
+      thread.setLoader(imports::get);
+
       EvalUtils.exec(file, module, thread);
     } catch (EvalException | InterruptedException ex) {
       // This exception class seems a bit unnecessary. Replace with EvalException?
       throw new StarlarkEvaluationException("Starlark evaluation error", ex);
     }
-
-    thread.mutability().freeze();
-
-    return thread;
+    return module;
   }
 
   /**
-   * Initialize and return a global frame containing the fake build API.
+   * Return the predeclared environment containing the fake build API.
    *
    * @param ruleInfoList the list of {@link RuleInfo} objects, to which rule() invocation
    *     information will be added
    * @param providerInfoList the list of {@link ProviderInfo} objects, to which provider()
    *     invocation information will be added
    */
-  private static Module globalFrame(
+  private static ImmutableMap<String, Object> getPredeclaredEnvironment(
       List<RuleInfoWrapper> ruleInfoList,
       List<ProviderInfoWrapper> providerInfoList,
       List<AspectInfoWrapper> aspectInfoList) {
@@ -608,8 +605,6 @@
 
     ImmutableMap.Builder<String, Object> envBuilder = ImmutableMap.builder();
 
-    envBuilder.putAll(Starlark.UNIVERSE);
-
     // Add stub declarations for Blaze-only things as a quick fix
     // for a broken test; see b/155126966 and b/155178103.
     // TODO(adonovan): fix properly ASAP.
@@ -676,7 +671,7 @@
     testingBootstrap.addBindingsToBuilder(envBuilder);
     addNonBootstrapGlobals(envBuilder);
 
-    return Module.createForBuiltins(envBuilder.build());
+    return envBuilder.build();
   }
 
   // TODO(cparsons): Remove this constant by migrating the contained symbols to bootstraps.
@@ -714,18 +709,6 @@
     }
   }
 
-  private static StarlarkThread createStarlarkThread(
-      StarlarkSemantics semantics, Module globals, Map<String, Module> imports) {
-    // We use the default print handler, which writes to stderr.
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("Skydoc"))
-            .setSemantics(semantics)
-            .setGlobals(globals)
-            .build();
-    thread.setLoader(imports::get);
-    return thread;
-  }
-
   /** Exception thrown when Starlark evaluation fails (due to malformed Starlark). */
   @VisibleForTesting
   static class StarlarkEvaluationException extends Exception {
diff --git a/src/main/java/net/starlark/java/cmd/Starlark.java b/src/main/java/net/starlark/java/cmd/Starlark.java
index 42b9e25..6077f6d 100644
--- a/src/main/java/net/starlark/java/cmd/Starlark.java
+++ b/src/main/java/net/starlark/java/cmd/Starlark.java
@@ -19,6 +19,7 @@
 import com.google.devtools.build.lib.syntax.Module;
 import com.google.devtools.build.lib.syntax.Mutability;
 import com.google.devtools.build.lib.syntax.ParserInput;
+import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
 import com.google.devtools.build.lib.syntax.SyntaxError;
 import java.io.BufferedReader;
@@ -44,22 +45,16 @@
   private final BufferedReader reader =
       new BufferedReader(new InputStreamReader(System.in, CHARSET));
   private final StarlarkThread thread;
-  private final Module module;
+  private final Module module = Module.create();
 
   // TODO(adonovan): set load-binds-globally option when we support load,
   // so that loads bound in one REPL chunk are visible in the next.
   private final FileOptions options = FileOptions.DEFAULT;
 
   {
-    thread =
-        StarlarkThread.builder(Mutability.create("interpreter"))
-            .useDefaultSemantics()
-            .setGlobals(
-                Module.createForBuiltins(com.google.devtools.build.lib.syntax.Starlark.UNIVERSE))
-            .build();
+    Mutability mu = Mutability.create("interpreter");
+    thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
     thread.setPrintHandler((th, msg) -> System.out.println(msg));
-
-    module = thread.getGlobals();
   }
 
   private String prompt() {
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/repository/skylark/StarlarkRepositoryContextTest.java b/src/test/java/com/google/devtools/build/lib/bazel/repository/skylark/StarlarkRepositoryContextTest.java
index 193d20b..537fb8c 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/repository/skylark/StarlarkRepositoryContextTest.java
+++ b/src/test/java/com/google/devtools/build/lib/bazel/repository/skylark/StarlarkRepositoryContextTest.java
@@ -79,8 +79,8 @@
   private Root root;
   private Path workspaceFile;
   private StarlarkRepositoryContext context;
-  private StarlarkThread thread =
-      StarlarkThread.builder(Mutability.create("test")).useDefaultSemantics().build();
+  private static final StarlarkThread thread =
+      new StarlarkThread(Mutability.create("test"), StarlarkSemantics.DEFAULT);
 
   private static String ONE_LINE_PATCH = "@@ -1,1 +1,2 @@\n line one\n+line two\n";
 
@@ -105,10 +105,9 @@
   }
 
   private static Object execAndEval(String... lines) {
-    try (Mutability mu = Mutability.create("impl")) {
-      StarlarkThread thread = StarlarkThread.builder(mu).useDefaultSemantics().build();
-      Module module = thread.getGlobals();
-      return EvalUtils.exec(ParserInput.fromLines(lines), FileOptions.DEFAULT, module, thread);
+    try {
+      return EvalUtils.exec(
+          ParserInput.fromLines(lines), FileOptions.DEFAULT, Module.create(), thread);
     } catch (Exception ex) { // SyntaxError | EvalException | InterruptedException
       throw new AssertionError("exec failed", ex);
     }
diff --git a/src/test/java/com/google/devtools/build/lib/packages/SelectTest.java b/src/test/java/com/google/devtools/build/lib/packages/SelectTest.java
index 7bec95b..89e4dc8 100644
--- a/src/test/java/com/google/devtools/build/lib/packages/SelectTest.java
+++ b/src/test/java/com/google/devtools/build/lib/packages/SelectTest.java
@@ -23,6 +23,7 @@
 import com.google.devtools.build.lib.syntax.Module;
 import com.google.devtools.build.lib.syntax.Mutability;
 import com.google.devtools.build.lib.syntax.ParserInput;
+import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
 import com.google.devtools.build.lib.syntax.SyntaxError;
 import java.util.List;
@@ -37,13 +38,12 @@
   private static Object eval(String expr)
       throws SyntaxError.Exception, EvalException, InterruptedException {
     ParserInput input = ParserInput.fromLines(expr);
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test"))
-            .setGlobals(Module.createForBuiltins(StarlarkLibrary.COMMON)) // select et al
-            .useDefaultSemantics()
-            .build();
-    Module module = thread.getGlobals();
-    return EvalUtils.eval(input, FileOptions.DEFAULT, module, thread);
+    Module module =
+        Module.withPredeclared(StarlarkSemantics.DEFAULT, /*predeclared=*/ StarlarkLibrary.COMMON);
+    try (Mutability mu = Mutability.create()) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      return EvalUtils.eval(input, FileOptions.DEFAULT, module, thread);
+    }
   }
 
   private static void assertFails(String expr, String wantError) {
diff --git a/src/test/java/com/google/devtools/build/lib/packages/StarlarkProviderTest.java b/src/test/java/com/google/devtools/build/lib/packages/StarlarkProviderTest.java
index 3f0592c..f44f6a7 100644
--- a/src/test/java/com/google/devtools/build/lib/packages/StarlarkProviderTest.java
+++ b/src/test/java/com/google/devtools/build/lib/packages/StarlarkProviderTest.java
@@ -23,6 +23,7 @@
 import com.google.devtools.build.lib.cmdline.Label;
 import com.google.devtools.build.lib.syntax.Mutability;
 import com.google.devtools.build.lib.syntax.Starlark;
+import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -132,16 +133,17 @@
 
   /** Instantiates a {@link StarlarkInfo} with fields a=1, b=2, c=3 (and nothing else). */
   private static StarlarkInfo instantiateWithA1B2C3(StarlarkProvider provider) throws Exception {
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test")).useDefaultSemantics().build();
-    Object result =
-        Starlark.call(
-            thread,
-            provider,
-            /*args=*/ ImmutableList.of(),
-            /*kwargs=*/ ImmutableMap.of("a", 1, "b", 2, "c", 3));
-    assertThat(result).isInstanceOf(StarlarkInfo.class);
-    return (StarlarkInfo) result;
+    try (Mutability mu = Mutability.create()) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      Object result =
+          Starlark.call(
+              thread,
+              provider,
+              /*args=*/ ImmutableList.of(),
+              /*kwargs=*/ ImmutableMap.of("a", 1, "b", 2, "c", 3));
+      assertThat(result).isInstanceOf(StarlarkInfo.class);
+      return (StarlarkInfo) result;
+    }
   }
 
   /** Asserts that a {@link StarlarkInfo} has fields a=1, b=2, c=3 (and nothing else). */
diff --git a/src/test/java/com/google/devtools/build/lib/profiler/memory/AllocationTrackerTest.java b/src/test/java/com/google/devtools/build/lib/profiler/memory/AllocationTrackerTest.java
index 8cb0d92..930cfb8 100644
--- a/src/test/java/com/google/devtools/build/lib/profiler/memory/AllocationTrackerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/profiler/memory/AllocationTrackerTest.java
@@ -32,6 +32,7 @@
 import com.google.devtools.build.lib.syntax.ParserInput;
 import com.google.devtools.build.lib.syntax.Starlark;
 import com.google.devtools.build.lib.syntax.StarlarkCallable;
+import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
 import com.google.devtools.build.lib.syntax.SyntaxError;
 import com.google.devtools.build.lib.syntax.TokenKind;
@@ -191,18 +192,16 @@
   private void exec(String... lines)
       throws SyntaxError.Exception, EvalException, InterruptedException {
     ParserInput input = ParserInput.create(Joiner.on("\n").join(lines), "a.star");
-    Mutability mu = Mutability.create("test");
-    StarlarkThread thread =
-        StarlarkThread.builder(mu)
-            .useDefaultSemantics()
-            .setGlobals(
-                Module.createForBuiltins(
-                    ImmutableMap.of(
-                        "sample", new SamplerValue(),
-                        "myrule", new MyRuleFunction())))
-            .build();
-    Module module = thread.getGlobals();
-    EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
+    Module module =
+        Module.withPredeclared(
+            StarlarkSemantics.DEFAULT,
+            ImmutableMap.of(
+                "sample", new SamplerValue(),
+                "myrule", new MyRuleFunction()));
+    try (Mutability mu = Mutability.create("test")) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
+    }
   }
 
   // A fake Bazel rule. The allocation tracker reports retained memory broken down by rule class.
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/StarlarkImportLookupFunctionTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/StarlarkImportLookupFunctionTest.java
index 840bc1f..131878e 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/StarlarkImportLookupFunctionTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/StarlarkImportLookupFunctionTest.java
@@ -407,7 +407,7 @@
         SkyframeExecutorTestUtils.evaluate(
             getSkyframeExecutor(), starlarkImportLookupKey, /*keepGoing=*/ false, reporter);
 
-    assertThat(result.get(starlarkImportLookupKey).getModule().getExportedBindings())
+    assertThat(result.get(starlarkImportLookupKey).getModule().getGlobals())
         .containsEntry("a_symbol", 5);
   }
 
diff --git a/src/test/java/com/google/devtools/build/lib/skylark/StarlarkRuleClassFunctionsTest.java b/src/test/java/com/google/devtools/build/lib/skylark/StarlarkRuleClassFunctionsTest.java
index 4f67c08..c0bc673 100644
--- a/src/test/java/com/google/devtools/build/lib/skylark/StarlarkRuleClassFunctionsTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skylark/StarlarkRuleClassFunctionsTest.java
@@ -61,7 +61,6 @@
 import com.google.devtools.build.lib.syntax.ParserInput;
 import com.google.devtools.build.lib.syntax.StarlarkFile;
 import com.google.devtools.build.lib.syntax.StarlarkList;
-import com.google.devtools.build.lib.syntax.StarlarkThread;
 import com.google.devtools.build.lib.syntax.SyntaxError;
 import com.google.devtools.build.lib.syntax.Tuple;
 import com.google.devtools.build.lib.syntax.util.EvaluationTestCase;
@@ -772,13 +771,13 @@
 
   private static void evalAndExport(EvaluationTestCase ev, String... lines) throws Exception {
     ParserInput input = ParserInput.fromLines(lines);
-    StarlarkThread thread = ev.getStarlarkThread();
-    Module module = thread.getGlobals();
+    Module module = ev.getModule();
     StarlarkFile file = EvalUtils.parseAndValidate(input, FileOptions.DEFAULT, module);
     if (!file.ok()) {
       throw new SyntaxError.Exception(file.errors());
     }
-    StarlarkImportLookupFunction.execAndExport(file, FAKE_LABEL, ev.getEventHandler(), thread);
+    StarlarkImportLookupFunction.execAndExport(
+        file, FAKE_LABEL, ev.getEventHandler(), module, ev.getStarlarkThread());
   }
 
   @Test
@@ -1385,7 +1384,7 @@
     assertThat(EvalUtils.isImmutable(makeStruct("a", makeList(null)))).isTrue();
     assertThat(EvalUtils.isImmutable(makeBigStruct(null))).isTrue();
 
-    Mutability mu = ev.getStarlarkThread().mutability();
+    Mutability mu = Mutability.create("test");
     assertThat(EvalUtils.isImmutable(Tuple.<Object>of(makeList(mu)))).isFalse();
     assertThat(EvalUtils.isImmutable(makeStruct("a", makeList(mu)))).isFalse();
     assertThat(EvalUtils.isImmutable(makeBigStruct(mu))).isFalse();
diff --git a/src/test/java/com/google/devtools/build/lib/skylark/util/BazelEvaluationTestCase.java b/src/test/java/com/google/devtools/build/lib/skylark/util/BazelEvaluationTestCase.java
index 4f8f44f..212ff8d 100644
--- a/src/test/java/com/google/devtools/build/lib/skylark/util/BazelEvaluationTestCase.java
+++ b/src/test/java/com/google/devtools/build/lib/skylark/util/BazelEvaluationTestCase.java
@@ -18,15 +18,11 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.devtools.build.lib.analysis.skylark.StarlarkModules;
 import com.google.devtools.build.lib.cmdline.Label;
-import com.google.devtools.build.lib.events.Event;
 import com.google.devtools.build.lib.packages.BazelModuleContext;
 import com.google.devtools.build.lib.packages.BazelStarlarkContext;
 import com.google.devtools.build.lib.packages.SymbolGenerator;
 import com.google.devtools.build.lib.rules.platform.PlatformCommon;
-import com.google.devtools.build.lib.syntax.Module;
-import com.google.devtools.build.lib.syntax.Mutability;
 import com.google.devtools.build.lib.syntax.Starlark;
-import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
 import com.google.devtools.build.lib.syntax.util.EvaluationTestCase;
 import com.google.devtools.build.lib.testutil.TestConstants;
@@ -39,28 +35,26 @@
 // Once the production code API has disentangled Thread and Module, make this rational.
 public final class BazelEvaluationTestCase extends EvaluationTestCase {
 
-  // Caution: called by the base class constructor.
   @Override
-  protected StarlarkThread newStarlarkThread(StarlarkSemantics semantics) {
-    ImmutableMap.Builder<String, Object> env = ImmutableMap.builder();
-    StarlarkModules.addStarlarkGlobalsToBuilder(env);
-    Starlark.addModule(env, new PlatformCommon());
+  protected Object newModuleHook(ImmutableMap.Builder<String, Object> predeclared) {
+    StarlarkModules.addStarlarkGlobalsToBuilder(predeclared);
+    Starlark.addModule(predeclared, new PlatformCommon());
 
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test"))
-            .setSemantics(semantics)
-            .setGlobals(
-                Module.createForBuiltins(env.build())
-                    .withClientData(
-                        BazelModuleContext.create(
-                            Label.parseAbsoluteUnchecked("//test:label", /*defaultToMain=*/ false),
-                            /*bzlTransitiveDigest=*/ new byte[0]))) // dummy value for tests
-            .build();
-    thread.setPrintHandler(Event.makeDebugPrintHandler(getEventHandler()));
+    // Return the module's client data. (This one uses dummy values for tests.)
+    return BazelModuleContext.create(
+        Label.parseAbsoluteUnchecked("//test:label", /*defaultToMain=*/ false),
+        /*bzlTransitiveDigest=*/ new byte[0]);
+  }
 
+  @Override
+  protected void newThreadHook(StarlarkThread thread) {
     // This StarlarkThread has no PackageContext, so attempts to create a rule will fail.
     // Rule creation is tested by StarlarkIntegrationTest.
 
+    // This is a poor approximation to the thread that Blaze would create
+    // for testing rule implementation functions. It has phase LOADING, for example.
+    // TODO(adonovan): stop creating threads in tests. This is the responsibility of the
+    // production code. Tests should provide only files and commands.
     new BazelStarlarkContext(
             BazelStarlarkContext.Phase.LOADING,
             TestConstants.TOOLS_REPOSITORY,
@@ -69,7 +63,5 @@
             new SymbolGenerator<>(new Object()),
             /*analysisRuleLabel=*/ null) // dummy value for tests
         .storeInThread(thread);
-
-    return thread;
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/BUILD b/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/BUILD
index 8a8f3c9..8d015bc 100644
--- a/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/BUILD
@@ -34,6 +34,7 @@
         "//src/main/java/com/google/devtools/build/lib/starlarkdebug/server",
         "//src/test/java/com/google/devtools/build/lib/events:testutil",
         "//third_party:guava",
+        "//third_party:jsr305",
         "//third_party:junit4",
         "//third_party:truth",
     ],
diff --git a/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/StarlarkDebugServerTest.java b/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/StarlarkDebugServerTest.java
index 35fee1d..a481c3a 100644
--- a/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/StarlarkDebugServerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/StarlarkDebugServerTest.java
@@ -42,10 +42,12 @@
 import com.google.devtools.build.lib.syntax.EvalException;
 import com.google.devtools.build.lib.syntax.EvalUtils;
 import com.google.devtools.build.lib.syntax.FileOptions;
+import com.google.devtools.build.lib.syntax.Module;
 import com.google.devtools.build.lib.syntax.Mutability;
 import com.google.devtools.build.lib.syntax.ParserInput;
 import com.google.devtools.build.lib.syntax.Starlark;
 import com.google.devtools.build.lib.syntax.StarlarkList;
+import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
 import com.google.devtools.build.lib.syntax.SyntaxError;
 import java.io.IOException;
@@ -60,6 +62,7 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
+import javax.annotation.Nullable;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -150,9 +153,8 @@
   @Test
   public void testPausedUntilStartDebuggingRequestReceived() throws Exception {
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]");
-    StarlarkThread thread = newStarlarkThread();
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     String threadName = evaluationThread.getName();
     long threadId = evaluationThread.getId();
 
@@ -189,8 +191,8 @@
     setBreakpoints(ImmutableList.of(breakpoint));
 
     // evaluate in two separate worker threads
-    execInWorkerThread(buildFile, newStarlarkThread());
-    execInWorkerThread(buildFile, newStarlarkThread());
+    execInWorkerThread(buildFile, null);
+    execInWorkerThread(buildFile, null);
 
     // wait for both breakpoints to be hit
     boolean paused =
@@ -218,13 +220,12 @@
   public void testPauseAtBreakpoint() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     String threadName = evaluationThread.getName();
     long threadId = evaluationThread.getId();
 
@@ -247,7 +248,6 @@
     sendStartDebuggingRequest();
     ParserInput buildFile =
         createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]", "z = 1");
-    StarlarkThread thread = newStarlarkThread();
 
     ImmutableList<Breakpoint> breakpoints =
         ImmutableList.of(
@@ -261,7 +261,7 @@
                 .build());
     setBreakpoints(breakpoints);
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     String threadName = evaluationThread.getName();
     long threadId = evaluationThread.getId();
     Breakpoint expectedBreakpoint = breakpoints.get(1);
@@ -282,7 +282,6 @@
   public void testPauseAtSatisfiedConditionalBreakpoint() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location location =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
@@ -290,7 +289,7 @@
         Breakpoint.newBuilder().setLocation(location).setExpression("x[0] == 1").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     String threadName = evaluationThread.getName();
     long threadId = evaluationThread.getId();
 
@@ -312,7 +311,6 @@
   public void testPauseAtInvalidConditionBreakpointWithError() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location location =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
@@ -320,7 +318,7 @@
         Breakpoint.newBuilder().setLocation(location).setExpression("z[0] == 1").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     String threadName = evaluationThread.getName();
     long threadId = evaluationThread.getId();
 
@@ -357,13 +355,12 @@
   public void testSimpleListFramesRequest() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -387,13 +384,12 @@
   public void testGetChildrenRequest() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -426,13 +422,13 @@
             "  b = 1",
             "  b + 1",
             "fn()");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setPath("/a/build/file/test.bzl").setLineNumber(6).build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(bzlFile, thread);
+    Module module = Module.create();
+    Thread evaluationThread = execInWorkerThread(bzlFile, module);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -455,7 +451,7 @@
                 Scope.newBuilder()
                     .setName("global")
                     .addBinding(getValueProto("c", 3))
-                    .addBinding(getValueProto("fn", thread.getGlobals().lookup("fn"))))
+                    .addBinding(getValueProto("fn", module.getGlobal("fn"))))
             .build());
 
     assertFramesEqualIgnoringValueIdentifiers(
@@ -472,7 +468,7 @@
                     .setName("global")
                     .addBinding(getValueProto("a", 1))
                     .addBinding(getValueProto("c", 3))
-                    .addBinding(getValueProto("fn", thread.getGlobals().lookup("fn"))))
+                    .addBinding(getValueProto("fn", module.getGlobal("fn"))))
             .build());
   }
 
@@ -480,13 +476,12 @@
   public void testEvaluateRequestWithExpression() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -507,13 +502,12 @@
   public void testEvaluateRequestWithAssignmentStatement() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -541,13 +535,12 @@
   public void testEvaluateRequestWithExpressionStatementMutatingState() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -575,13 +568,12 @@
   public void testEvaluateRequestThrowingException() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -609,13 +601,12 @@
             "  return a",
             "x = fn()",
             "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(4).setPath("/a/build/file/test.bzl").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(bzlFile, thread);
+    Thread evaluationThread = execInWorkerThread(bzlFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -657,13 +648,12 @@
             "  return a",
             "x = fn()",
             "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(4).setPath("/a/build/file/test.bzl").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(bzlFile, thread);
+    Thread evaluationThread = execInWorkerThread(bzlFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -700,13 +690,12 @@
             "  return a",
             "x = fn()",
             "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/test.bzl").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(bzlFile, thread);
+    Thread evaluationThread = execInWorkerThread(bzlFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -772,25 +761,23 @@
     return event.getListFrames();
   }
 
-  private static StarlarkThread newStarlarkThread() {
-    Mutability mutability = Mutability.create("test");
-    return StarlarkThread.builder(mutability).useDefaultSemantics().build();
-  }
-
   private static ParserInput createInput(String filename, String... lines) {
     return ParserInput.create(Joiner.on("\n").join(lines), filename);
   }
 
   /**
-   * Creates and starts a worker thread parsing, resolving, and executing the given Starlark file in
-   * the given environment.
+   * Creates and starts a worker thread parsing, resolving, and executing the given Starlark file to
+   * populate the specified module, or if none is given, in a fresh module with a default
+   * environment.
    */
-  private static Thread execInWorkerThread(ParserInput input, StarlarkThread thread) {
+  private static Thread execInWorkerThread(ParserInput input, @Nullable Module module) {
     Thread javaThread =
         new Thread(
             () -> {
-              try {
-                EvalUtils.exec(input, FileOptions.DEFAULT, thread.getGlobals(), thread);
+              try (Mutability mu = Mutability.create("test")) {
+                StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+                EvalUtils.exec(
+                    input, FileOptions.DEFAULT, module != null ? module : Module.create(), thread);
               } catch (SyntaxError.Exception | EvalException | InterruptedException ex) {
                 throw new AssertionError(ex);
               }
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/CpuProfilerTest.java b/src/test/java/com/google/devtools/build/lib/syntax/CpuProfilerTest.java
index 599fd40..c945dfb 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/CpuProfilerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/CpuProfilerTest.java
@@ -62,12 +62,11 @@
             "f()");
 
     // Execute the workload.
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test"))
-            .setGlobals(Module.createForBuiltins(Starlark.UNIVERSE))
-            .useDefaultSemantics()
-            .build();
-    EvalUtils.exec(input, FileOptions.DEFAULT, thread.getGlobals(), thread);
+    Module module = Module.create();
+    try (Mutability mu = Mutability.create("test")) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
+    }
 
     Starlark.stopCpuProfile();
 
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/EvaluationTest.java b/src/test/java/com/google/devtools/build/lib/syntax/EvaluationTest.java
index 9bcd35c..0f6534b 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/EvaluationTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/EvaluationTest.java
@@ -29,18 +29,16 @@
 // TODO(adonovan): separate tests of parser, resolver, Starlark core evaluator,
 // and BUILD and .bzl features.
 @RunWith(JUnit4.class)
-public final class EvaluationTest extends EvaluationTestCase {
+public final class EvaluationTest {
+
+  private final EvaluationTestCase ev = new EvaluationTestCase();
 
   @Test
   public void testExecutionStopsAtFirstError() throws Exception {
     List<String> printEvents = new ArrayList<>();
-    StarlarkThread thread =
-        createStarlarkThread(/*printHandler=*/ (_thread, msg) -> printEvents.add(msg));
     ParserInput input = ParserInput.fromLines("print('hello'); x = 1//0; print('goodbye')");
-
-    Module module = thread.getGlobals();
-    assertThrows(
-        EvalException.class, () -> EvalUtils.exec(input, FileOptions.DEFAULT, module, thread));
+    InterruptFunction interrupt = new InterruptFunction();
+    assertThrows(EvalException.class, () -> execWithInterrupt(input, interrupt, printEvents));
 
     // Only expect hello, should have been an error before goodbye.
     assertThat(printEvents.toString()).isEqualTo("[hello]");
@@ -48,93 +46,52 @@
 
   @Test
   public void testExecutionNotStartedOnInterrupt() throws Exception {
-    StarlarkThread thread =
-        createStarlarkThread(
-            /*printHandler=*/ (_thread, msg) -> {
-              throw new AssertionError("print statement was reached");
-            });
-    ParserInput input = ParserInput.fromLines("print('hello');");
-    Module module = thread.getGlobals();
+    ParserInput input = ParserInput.fromLines("print('hello')");
+    List<String> printEvents = new ArrayList<>();
+    Thread.currentThread().interrupt();
+    InterruptFunction interrupt = new InterruptFunction();
+    assertThrows(
+        InterruptedException.class, () -> execWithInterrupt(input, interrupt, printEvents));
 
-    try {
-      Thread.currentThread().interrupt();
-      assertThrows(
-          InterruptedException.class,
-          () -> EvalUtils.exec(input, FileOptions.DEFAULT, module, thread));
-    } finally {
-      // Reset interrupt bit in case the test failed to do so.
-      Thread.interrupted();
-    }
+    // Execution didn't reach print.
+    assertThat(printEvents).isEmpty();
   }
 
   @Test
   public void testForLoopAbortedOnInterrupt() throws Exception {
-    StarlarkThread thread = createStarlarkThread((th, msg) -> {});
-    InterruptFunction interruptFunction = new InterruptFunction();
-    Module module = thread.getGlobals();
-    module.put("interrupt", interruptFunction);
-
     ParserInput input =
         ParserInput.fromLines(
-            "def foo():", // Can't declare for loops at top level, so wrap with a function.
+            "def f():", //
             "  for i in range(100):",
             "    interrupt(i == 5)",
-            "foo()");
+            "f()");
+    InterruptFunction interrupt = new InterruptFunction();
+    assertThrows(
+        InterruptedException.class, () -> execWithInterrupt(input, interrupt, new ArrayList<>()));
 
-    try {
-      assertThrows(
-          InterruptedException.class,
-          () -> EvalUtils.exec(input, FileOptions.DEFAULT, module, thread));
-    } finally {
-      // Reset interrupt bit in case the test failed to do so.
-      Thread.interrupted();
-    }
-
-    assertThat(interruptFunction.callCount).isEqualTo(6);
+    assertThat(interrupt.callCount).isEqualTo(6);
   }
 
   @Test
   public void testForComprehensionAbortedOnInterrupt() throws Exception {
-    StarlarkThread thread = createStarlarkThread((th, msg) -> {});
-    Module module = thread.getGlobals();
-    InterruptFunction interruptFunction = new InterruptFunction();
-    module.put("interrupt", interruptFunction);
-
     ParserInput input = ParserInput.fromLines("[interrupt(i == 5) for i in range(100)]");
+    InterruptFunction interrupt = new InterruptFunction();
+    assertThrows(
+        InterruptedException.class, () -> execWithInterrupt(input, interrupt, new ArrayList<>()));
 
-    try {
-      assertThrows(
-          InterruptedException.class,
-          () -> EvalUtils.exec(input, FileOptions.DEFAULT, module, thread));
-    } finally {
-      // Reset interrupt bit in case the test failed to do so.
-      Thread.interrupted();
-    }
-
-    assertThat(interruptFunction.callCount).isEqualTo(6);
+    assertThat(interrupt.callCount).isEqualTo(6);
   }
 
   @Test
   public void testFunctionCallsNotStartedOnInterrupt() throws Exception {
-    StarlarkThread thread = createStarlarkThread((th, msg) -> {});
-    Module module = thread.getGlobals();
-    InterruptFunction interruptFunction = new InterruptFunction();
-    module.put("interrupt", interruptFunction);
-
     ParserInput input =
         ParserInput.fromLines("interrupt(False); interrupt(True); interrupt(False);");
-
-    try {
-      assertThrows(
-          InterruptedException.class,
-          () -> EvalUtils.exec(input, FileOptions.DEFAULT, module, thread));
-    } finally {
-      // Reset interrupt bit in case the test failed to do so.
-      Thread.interrupted();
-    }
+    InterruptFunction interrupt = new InterruptFunction();
+    assertThrows(
+        InterruptedException.class, () -> execWithInterrupt(input, interrupt, new ArrayList<>()));
 
     // Third call shouldn't happen.
-    assertThat(interruptFunction.callCount).isEqualTo(2);
+    assertThat(interrupt.callCount).isEqualTo(2);
   }
 
   private static class InterruptFunction implements StarlarkCallable {
@@ -156,21 +113,25 @@
     }
   }
 
-  private static StarlarkThread createStarlarkThread(StarlarkThread.PrintHandler printHandler) {
-    Mutability mu = Mutability.create("test");
-    StarlarkThread thread =
-        StarlarkThread.builder(mu)
-            .useDefaultSemantics()
-            // Provide the UNIVERSE for print... this should not be necessary
-            .setGlobals(Module.createForBuiltins(Starlark.UNIVERSE))
-            .build();
-    thread.setPrintHandler(printHandler);
-    return thread;
+  // Executes input, with the specified 'interrupt' predeclared built-in, gather print events in
+  // printEvents.
+  private static void execWithInterrupt(
+      ParserInput input, InterruptFunction interrupt, List<String> printEvents) throws Exception {
+    Module module =
+        Module.withPredeclared(StarlarkSemantics.DEFAULT, ImmutableMap.of("interrupt", interrupt));
+    try (Mutability mu = Mutability.create("test")) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      thread.setPrintHandler((_thread, msg) -> printEvents.add(msg));
+      EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
+    } finally {
+      // Reset interrupt bit in case the test failed to do so.
+      Thread.interrupted();
+    }
   }
 
   @Test
   public void testExprs() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("'%sx' % 'foo' + 'bar1'", "fooxbar1")
         .testExpression("('%sx' % 'foo') + 'bar2'", "fooxbar2")
         .testExpression("'%sx' % ('foo' + 'bar3')", "foobar3x")
@@ -184,17 +145,17 @@
 
   @Test
   public void testListExprs() throws Exception {
-    new Scenario().testExactOrder("[1, 2, 3]", 1, 2, 3).testExactOrder("(1, 2, 3)", 1, 2, 3);
+    ev.new Scenario().testExactOrder("[1, 2, 3]", 1, 2, 3).testExactOrder("(1, 2, 3)", 1, 2, 3);
   }
 
   @Test
   public void testStringFormatMultipleArgs() throws Exception {
-    new Scenario().testExpression("'%sY%s' % ('X', 'Z')", "XYZ");
+    ev.new Scenario().testExpression("'%sY%s' % ('X', 'Z')", "XYZ");
   }
 
   @Test
   public void testConditionalExpressions() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("1 if True else 2", 1)
         .testExpression("1 if False else 2", 2)
         .testExpression("1 + 2 if 3 + 4 else 5 + 6", 3);
@@ -202,7 +163,7 @@
 
   @Test
   public void testListComparison() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("[] < [1]", true)
         .testExpression("[1] < [1, 1]", true)
         .testExpression("[1, 1] < [1, 2]", true)
@@ -239,7 +200,7 @@
           }
         };
 
-    new Scenario()
+    ev.new Scenario()
         .update(sum.getName(), sum)
         .testExpression("sum(1, 2, 3, 4, 5, 6)", 21)
         .testExpression("sum", sum)
@@ -248,7 +209,7 @@
 
   @Test
   public void testNotCallInt() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("sum = 123456")
         .testLookup("sum", 123456)
         .testIfExactError("'int' object is not callable", "sum(1, 2, 3, 4, 5, 6)")
@@ -257,7 +218,7 @@
 
   @Test
   public void testComplexFunctionCall() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("functions = [min, max]", "l = [1,2]")
         .testEval("(functions[0](l), functions[1](l))", "(1, 2)");
   }
@@ -281,7 +242,7 @@
           }
         };
 
-    new Scenario()
+    ev.new Scenario()
         .update(kwargs.getName(), kwargs)
         .testEval(
             "kwargs(foo=1, bar='bar', wiz=[1,2,3]).items()",
@@ -293,7 +254,7 @@
 
   @Test
   public void testModulo() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("6 % 2", 0)
         .testExpression("6 % 4", 2)
         .testExpression("3 % 6", 3)
@@ -305,7 +266,7 @@
 
   @Test
   public void testMult() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("6 * 7", 42)
         .testExpression("3 * 'ab'", "ababab")
         .testExpression("0 * 'ab'", "")
@@ -316,12 +277,12 @@
 
   @Test
   public void testSlashOperatorIsForbidden() throws Exception {
-    new Scenario().testIfErrorContains("The `/` operator is not allowed.", "5 / 2");
+    ev.new Scenario().testIfErrorContains("The `/` operator is not allowed.", "5 / 2");
   }
 
   @Test
   public void testFloorDivision() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("6 // 2", 3)
         .testExpression("6 // 4", 1)
         .testExpression("3 // 6", 0)
@@ -335,7 +296,7 @@
 
   @Test
   public void testCheckedArithmetic() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains("integer overflow", "2000000000 + 2000000000")
         .testIfErrorContains("integer overflow", "1234567890 * 987654321")
         .testIfErrorContains("integer overflow", "- 2000000000 - 2000000000")
@@ -347,7 +308,7 @@
 
   @Test
   public void testOperatorPrecedence() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("2 + 3 * 4", 14)
         .testExpression("2 + 3 // 4", 2)
         .testExpression("2 * 3 + 4 // -2", 4);
@@ -355,36 +316,36 @@
 
   @Test
   public void testConcatStrings() throws Exception {
-    new Scenario().testExpression("'foo' + 'bar'", "foobar");
+    ev.new Scenario().testExpression("'foo' + 'bar'", "foobar");
   }
 
   @Test
   public void testConcatLists() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExactOrder("[1,2] + [3,4]", 1, 2, 3, 4)
         .testExactOrder("(1,2)", 1, 2)
         .testExactOrder("(1,2) + (3,4)", 1, 2, 3, 4);
 
     // TODO(fwe): cannot be handled by current testing suite
     // list
-    Object x = eval("[1,2] + [3,4]");
+    Object x = ev.eval("[1,2] + [3,4]");
     assertThat((Iterable<?>) x).containsExactly(1, 2, 3, 4).inOrder();
     assertThat(x).isInstanceOf(StarlarkList.class);
     assertThat(EvalUtils.isImmutable(x)).isFalse();
 
     // tuple
-    x = eval("(1,2) + (3,4)");
+    x = ev.eval("(1,2) + (3,4)");
     assertThat((Iterable<?>) x).containsExactly(1, 2, 3, 4).inOrder();
     assertThat(x).isInstanceOf(Tuple.class);
     assertThat(x).isEqualTo(Tuple.of(1, 2, 3, 4));
     assertThat(EvalUtils.isImmutable(x)).isTrue();
 
-    checkEvalError("unsupported binary operation: tuple + list", "(1,2) + [3,4]");
+    ev.checkEvalError("unsupported binary operation: tuple + list", "(1,2) + [3,4]");
   }
 
   @Test
   public void testListComprehensions() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExactOrder("['foo/%s.java' % x for x in []]")
         .testExactOrder(
             "['foo/%s.java' % y for y in ['bar', 'wiz', 'quux']]",
@@ -426,10 +387,10 @@
 
   @Test
   public void testNestedListComprehensions() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("li = [[1, 2], [3, 4]]")
         .testExactOrder("[j for i in li for j in i]", 1, 2, 3, 4);
-    new Scenario()
+    ev.new Scenario()
         .setUp("input = [['abc'], ['def', 'ghi']]\n")
         .testExactOrder(
             "['%s %s' % (b, c) for a in input for b in a for c in b.elems()]",
@@ -438,7 +399,7 @@
 
   @Test
   public void testListComprehensionsMultipleVariables() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testEval("[x + y for x, y in [(1, 2), (3, 4)]]", "[3, 7]")
         .testEval("[z + t for (z, t) in [[1, 2], [3, 4]]]", "[3, 7]");
   }
@@ -447,52 +408,52 @@
   public void testSequenceAssignment() throws Exception {
     // assignment to empty list/tuple
     // See https://github.com/bazelbuild/starlark/issues/93 for discussion
-    checkEvalError(
+    ev.checkEvalError(
         "can't assign to ()", //
         "() = ()");
-    checkEvalError(
+    ev.checkEvalError(
         "can't assign to ()", //
         "() = 1");
-    checkEvalError(
+    ev.checkEvalError(
         "can't assign to []", //
         "[] = ()");
 
     // RHS not iterable
-    checkEvalError(
+    ev.checkEvalError(
         "got 'int' in sequence assignment", //
         "x, y = 1");
-    checkEvalError(
+    ev.checkEvalError(
         "got 'int' in sequence assignment", //
         "(x,) = 1");
-    checkEvalError(
+    ev.checkEvalError(
         "got 'int' in sequence assignment", //
         "[x] = 1");
 
     // too few
-    checkEvalError(
+    ev.checkEvalError(
         "too few values to unpack (got 0, want 2)", //
         "x, y = ()");
-    checkEvalError(
+    ev.checkEvalError(
         "too few values to unpack (got 0, want 2)", //
         "[x, y] = ()");
 
     // just right
-    exec("x, y = 1, 2");
-    exec("[x, y] = 1, 2");
-    exec("(x,) = [1]");
+    ev.exec("x, y = 1, 2");
+    ev.exec("[x, y] = 1, 2");
+    ev.exec("(x,) = [1]");
 
     // too many
-    checkEvalError(
+    ev.checkEvalError(
         "too many values to unpack (got 3, want 2)", //
         "x, y = 1, 2, 3");
-    checkEvalError(
+    ev.checkEvalError(
         "too many values to unpack (got 3, want 2)", //
         "[x, y] = 1, 2, 3");
   }
 
   @Test
   public void testListComprehensionsMultipleVariablesFail() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "too few values to unpack (got 2, want 3)", //
             "[x + y for x, y, z in [(1, 2), (3, 4)]]")
@@ -500,29 +461,29 @@
             "got 'int' in sequence assignment", //
             "[x + y for x, y in (1, 2)]");
 
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "too few values to unpack (got 2, want 3)", //
             "def foo (): return [x + y for x, y, z in [(1, 2), (3, 4)]]",
             "foo()");
 
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "got 'int' in sequence assignment", //
             "def bar (): return [x + y for x, y in (1, 2)]",
             "bar()");
 
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "too few values to unpack (got 2, want 3)", //
             "[x + y for x, y, z in [(1, 2), (3, 4)]]");
 
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "got 'int' in sequence assignment", //
             "[x2 + y2 for x2, y2 in (1, 2)]");
 
-    new Scenario()
+    ev.new Scenario()
         // Behavior varies across Python2 and 3 and Starlark in {Go,Java}.
         // See https://github.com/bazelbuild/starlark/issues/93 for discussion.
         .testIfErrorContains(
@@ -532,7 +493,7 @@
 
   @Test
   public void testListComprehensionsWithFiltering() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("range3 = [0, 1, 2]")
         .testEval("[a for a in (4, None, 2, None, 1) if a != None]", "[4, 2, 1]")
         .testEval("[b+c for b in [0, 1, 2] for c in [0, 1, 2] if b + c > 2]", "[3, 3, 4]")
@@ -548,7 +509,7 @@
     // This exercises the .bzl file behavior. This is a dynamic error.
     // (The error message for BUILD files is slightly different (no "local")
     // because it doesn't record the scope in the syntax tree.)
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "local variable 'y' is referenced before assignment", //
             "[x for x in (1, 2) if y for y in (3, 4)]");
@@ -563,16 +524,16 @@
   private static void execBUILD(String... lines)
       throws SyntaxError.Exception, EvalException, InterruptedException {
     ParserInput input = ParserInput.fromLines(lines);
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test")).useDefaultSemantics().build();
-    Module module = thread.getGlobals();
     FileOptions options = FileOptions.builder().recordScope(false).build();
-    EvalUtils.exec(input, options, module, thread);
+    try (Mutability mu = Mutability.create("test")) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      EvalUtils.exec(input, options, Module.create(), thread);
+    }
   }
 
   @Test
   public void testTupleDestructuring() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("a, b = 1, 2")
         .testLookup("a", 1)
         .testLookup("b", 2)
@@ -583,12 +544,12 @@
 
   @Test
   public void testSingleTuple() throws Exception {
-    new Scenario().setUp("(a,) = [1]").testLookup("a", 1);
+    ev.new Scenario().setUp("(a,) = [1]").testLookup("a", 1);
   }
 
   @Test
   public void testHeterogeneousDict() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("d = {'str': 1, 2: 3}", "a = d['str']", "b = d[2]")
         .testLookup("a", 1)
         .testLookup("b", 3);
@@ -596,19 +557,19 @@
 
   @Test
   public void testAccessDictWithATupleKey() throws Exception {
-    new Scenario().setUp("x = {(1, 2): 3}[1, 2]").testLookup("x", 3);
+    ev.new Scenario().setUp("x = {(1, 2): 3}[1, 2]").testLookup("x", 3);
   }
 
   @Test
   public void testDictWithDuplicatedKey() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "Duplicated key \"str\" when creating dictionary", "{'str': 1, 'x': 2, 'str': 3}");
   }
 
   @Test
   public void testRecursiveTupleDestructuring() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("((a, b), (c, d)) = [(1, 2), (3, 4)]")
         .testLookup("a", 1)
         .testLookup("b", 2)
@@ -619,7 +580,7 @@
   @Test
   public void testListComprehensionAtTopLevel() throws Exception {
     // It is allowed to have a loop variable with the same name as a global variable.
-    new Scenario()
+    ev.new Scenario()
         .update("x", 42)
         .setUp("y = [x + 1 for x in [1,2,3]]")
         .testExactOrder("y", 2, 3, 4);
@@ -627,7 +588,7 @@
 
   @Test
   public void testDictComprehensions() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("{a : a for a in []}", Collections.emptyMap())
         .testExpression("{b : b for b in [1, 2]}", ImmutableMap.of(1, 1, 2, 2))
         .testExpression(
@@ -642,12 +603,12 @@
 
   @Test
   public void testDictComprehensionOnNonIterable() throws Exception {
-    new Scenario().testIfExactError("type 'int' is not iterable", "{k : k for k in 3}");
+    ev.new Scenario().testIfExactError("type 'int' is not iterable", "{k : k for k in 3}");
   }
 
   @Test
   public void testDictComprehension_ManyClauses() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression(
             "{x : x * y for x in range(1, 10) if x % 2 == 0 for y in range(1, 10) if y == x}",
             ImmutableMap.of(2, 4, 4, 16, 6, 36, 8, 64));
@@ -655,7 +616,7 @@
 
   @Test
   public void testDictComprehensions_MultipleKey() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("{x : x for x in [1, 2, 1]}", ImmutableMap.of(1, 1, 2, 2))
         .testExpression(
             "{y : y for y in ['ab', 'c', 'a' + 'b']}", ImmutableMap.of("ab", "ab", "c", "c"));
@@ -663,7 +624,7 @@
 
   @Test
   public void testListConcatenation() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("[1, 2] + [3, 4]", StarlarkList.of(null, 1, 2, 3, 4))
         .testExpression("(1, 2) + (3, 4)", Tuple.of(1, 2, 3, 4))
         .testIfExactError("unsupported binary operation: list + tuple", "[1, 2] + (3, 4)")
@@ -673,7 +634,7 @@
   @Test
   public void testListMultiply() throws Exception {
     Mutability mu = Mutability.create("test");
-    new Scenario()
+    ev.new Scenario()
         .testExpression("[1, 2, 3] * 1", StarlarkList.of(mu, 1, 2, 3))
         .testExpression("[1, 2] * 2", StarlarkList.of(mu, 1, 2, 1, 2))
         .testExpression("[1, 2] * 3", StarlarkList.of(mu, 1, 2, 1, 2, 1, 2))
@@ -690,7 +651,7 @@
 
   @Test
   public void testTupleMultiply() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("(1, 2, 3) * 1", Tuple.of(1, 2, 3))
         .testExpression("(1, 2) * 2", Tuple.of(1, 2, 1, 2))
         .testExpression("(1, 2) * 3", Tuple.of(1, 2, 1, 2, 1, 2))
@@ -707,34 +668,34 @@
 
   @Test
   public void testListComprehensionFailsOnNonSequence() throws Exception {
-    new Scenario().testIfErrorContains("type 'int' is not iterable", "[x + 1 for x in 123]");
+    ev.new Scenario().testIfErrorContains("type 'int' is not iterable", "[x + 1 for x in 123]");
   }
 
   @Test
   public void testListComprehensionOnStringIsForbidden() throws Exception {
-    new Scenario().testIfErrorContains("type 'string' is not iterable", "[x for x in 'abc']");
+    ev.new Scenario().testIfErrorContains("type 'string' is not iterable", "[x for x in 'abc']");
   }
 
   @Test
   public void testInvalidAssignment() throws Exception {
-    new Scenario().testIfErrorContains("cannot assign to 'x + 1'", "x + 1 = 2");
+    ev.new Scenario().testIfErrorContains("cannot assign to 'x + 1'", "x + 1 = 2");
   }
 
   @Test
   public void testListComprehensionOnDictionary() throws Exception {
-    new Scenario().testExactOrder("['var_' + n for n in {'a':1,'b':2}]", "var_a", "var_b");
+    ev.new Scenario().testExactOrder("['var_' + n for n in {'a':1,'b':2}]", "var_a", "var_b");
   }
 
   @Test
   public void testListComprehensionOnDictionaryCompositeExpression() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("d = {1:'a',2:'b'}", "l = [d[x] for x in d]")
         .testLookup("l", StarlarkList.of(null, "a", "b"));
   }
 
   @Test
   public void testListComprehensionUpdate() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("xs = [1, 2, 3]")
         .testIfErrorContains(
             "list value is temporarily immutable due to active for-loop iteration",
@@ -743,7 +704,7 @@
 
   @Test
   public void testNestedListComprehensionUpdate() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("xs = [1, 2, 3]")
         .testIfErrorContains(
             "list value is temporarily immutable due to active for-loop iteration",
@@ -752,7 +713,7 @@
 
   @Test
   public void testListComprehensionUpdateInClause() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("xs = [1, 2, 3]")
         .testIfErrorContains(
             "list value is temporarily immutable due to active for-loop iteration",
@@ -763,7 +724,7 @@
 
   @Test
   public void testDictComprehensionUpdate() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("xs = {1:1, 2:2, 3:3}")
         .testIfErrorContains(
             "dict value is temporarily immutable due to active for-loop iteration",
@@ -773,7 +734,7 @@
   @Test
   public void testListComprehensionScope() throws Exception {
     // Test list comprehension creates a scope, so outer variables kept unchanged
-    new Scenario()
+    ev.new Scenario()
         .setUp("x = 1", "l = [x * 3 for x in [2]]", "y = x")
         .testEval("y", "1")
         .testEval("l", "[6]");
@@ -781,7 +742,7 @@
 
   @Test
   public void testInOperator() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("'b' in ['a', 'b']", Boolean.TRUE)
         .testExpression("'c' in ['a', 'b']", Boolean.FALSE)
         .testExpression("'b' in ('a', 'b')", Boolean.TRUE)
@@ -795,7 +756,7 @@
 
   @Test
   public void testNotInOperator() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("'b' not in ['a', 'b']", Boolean.FALSE)
         .testExpression("'c' not in ['a', 'b']", Boolean.TRUE)
         .testExpression("'b' not in ('a', 'b')", Boolean.FALSE)
@@ -809,7 +770,7 @@
 
   @Test
   public void testInFail() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "'in <string>' requires string as left operand, not 'int'", "1 in '123'")
         .testIfErrorContains("unsupported binary operation: string in int", "'a' in 1");
@@ -817,10 +778,10 @@
 
   @Test
   public void testInCompositeForPrecedence() throws Exception {
-    new Scenario().testExpression("not 'a' in ['a'] or 0", 0);
+    ev.new Scenario().testExpression("not 'a' in ['a'] or 0", 0);
   }
 
-  private StarlarkValue createObjWithStr() {
+  private static StarlarkValue createObjWithStr() {
     return new StarlarkValue() {
       @Override
       public void repr(Printer printer) {
@@ -831,7 +792,9 @@
 
   @Test
   public void testPercentOnObjWithStr() throws Exception {
-    new Scenario().update("obj", createObjWithStr()).testExpression("'%s' % obj", "<str marker>");
+    ev.new Scenario()
+        .update("obj", createObjWithStr())
+        .testExpression("'%s' % obj", "<str marker>");
   }
 
   private static class Dummy implements StarlarkValue {}
@@ -839,7 +802,7 @@
   @Test
   public void testStringRepresentationsOfArbitraryObjects() throws Exception {
     String dummy = "<unknown object com.google.devtools.build.lib.syntax.EvaluationTest$Dummy>";
-    new Scenario()
+    ev.new Scenario()
         .update("dummy", new Dummy())
         .testExpression("str(dummy)", dummy)
         .testExpression("repr(dummy)", dummy)
@@ -850,10 +813,10 @@
 
   @Test
   public void testPercentOnTupleOfDummyValues() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .update("obj", createObjWithStr())
         .testExpression("'%s %s' % (obj, obj)", "<str marker> <str marker>");
-    new Scenario()
+    ev.new Scenario()
         .update("unknown", new Dummy())
         .testExpression(
             "'%s %s' % (unknown, unknown)",
@@ -863,25 +826,25 @@
 
   @Test
   public void testPercOnObjectInvalidFormat() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .update("obj", createObjWithStr())
         .testIfExactError("invalid argument <str marker> for format pattern %d", "'%d' % obj");
   }
 
   @Test
   public void testDictKeys() throws Exception {
-    new Scenario().testExactOrder("{'a': 1}.keys() + ['b', 'c']", "a", "b", "c");
+    ev.new Scenario().testExactOrder("{'a': 1}.keys() + ['b', 'c']", "a", "b", "c");
   }
 
   @Test
   public void testDictKeysTooManyArgs() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfExactError("keys() got unexpected positional argument", "{'a': 1}.keys('abc')");
   }
 
   @Test
   public void testDictKeysTooManyKeyArgs() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfExactError(
             "keys() got unexpected keyword argument 'arg'", "{'a': 1}.keys(arg='abc')");
   }
@@ -889,37 +852,36 @@
   @Test
   public void testDictKeysDuplicateKeyArgs() throws Exception {
     // f(a=1, a=2) is caught statically by the resolver.
-    new Scenario()
+    ev.new Scenario()
         .testIfExactError(
             "int() got multiple values for argument 'base'", "int('1', base=10, **dict(base=16))");
   }
 
   @Test
   public void testArgBothPosKey() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "int() got multiple values for argument 'base'", "int('2', 3, base=3)");
   }
 
   @Test
   public void testStaticNameResolution() throws Exception {
-    new Scenario().testIfErrorContains("name 'foo' is not defined", "[foo for x in []]");
+    ev.new Scenario().testIfErrorContains("name 'foo' is not defined", "[foo for x in []]");
   }
 
   @Test
   public void testExec() throws Exception {
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test")).useDefaultSemantics().build();
-    Module module = thread.getGlobals();
-    EvalUtils.exec(
+    ParserInput input =
         ParserInput.fromLines(
             "# a file in the build language",
             "",
-            "x = [1, 2, 'foo', 4] + [1, 2, \"%s%d\" % ('foo', 1)]"),
-        FileOptions.DEFAULT,
-        module,
-        thread);
-    assertThat(thread.getGlobals().lookup("x"))
+            "x = [1, 2, 'foo', 4] + [1, 2, \"%s%d\" % ('foo', 1)]");
+    Module module = Module.create();
+    try (Mutability mu = Mutability.create("test")) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
+    }
+    assertThat(module.getGlobal("x"))
         .isEqualTo(StarlarkList.of(/*mutability=*/ null, 1, 2, "foo", 4, 1, 2, "foo1"));
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/ResolverTest.java b/src/test/java/com/google/devtools/build/lib/syntax/ResolverTest.java
index 0482057..87227f5 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/ResolverTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/ResolverTest.java
@@ -29,8 +29,7 @@
   // Resolves a file using the current options.
   private StarlarkFile resolveFile(String... lines) throws SyntaxError.Exception {
     ParserInput input = ParserInput.fromLines(lines);
-    Module module = Module.createForBuiltins(Starlark.UNIVERSE);
-    return EvalUtils.parseAndValidate(input, options.build(), module);
+    return EvalUtils.parseAndValidate(input, options.build(), Module.create());
   }
 
   // Assertions that parsing and resolution succeeds.
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkFlagGuardingTest.java b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkFlagGuardingTest.java
index f1ea610..0083f9d 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkFlagGuardingTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkFlagGuardingTest.java
@@ -13,6 +13,7 @@
 // limitations under the License.
 package com.google.devtools.build.lib.syntax;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.devtools.build.lib.syntax.StarlarkSemantics.FlagIdentifier;
 import com.google.devtools.build.lib.syntax.util.EvaluationTestCase;
 import net.starlark.java.annot.Param;
@@ -27,7 +28,9 @@
  * parameters with semantic flags.
  */
 @RunWith(JUnit4.class)
-public final class StarlarkFlagGuardingTest extends EvaluationTestCase {
+public final class StarlarkFlagGuardingTest {
+
+  private EvaluationTestCase ev = new EvaluationTestCase();
 
   /** Mock containing exposed methods for flag-guarding tests. */
   @StarlarkBuiltin(name = "Mock", doc = "")
@@ -126,23 +129,23 @@
 
   @Test
   public void testPositionalsOnlyGuardedMethod() throws Exception {
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .update("mock", new Mock())
         .testEval(
             "mock.positionals_only_method(1, True, 3)", "'positionals_only_method(1, true, 3)'");
 
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .update("mock", new Mock())
         .testIfErrorContains(
             "in call to positionals_only_method(), parameter 'b' got value of type 'int', want"
                 + " 'bool'",
             "mock.positionals_only_method(1, 3)");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testEval("mock.positionals_only_method(1, 3)", "'positionals_only_method(1, false, 3)'");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testIfErrorContains(
             "in call to positionals_only_method(), parameter 'c' got value of type 'bool', want"
@@ -152,22 +155,22 @@
 
   @Test
   public void testKeywordOnlyGuardedMethod() throws Exception {
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .update("mock", new Mock())
         .testEval(
             "mock.keywords_only_method(a=1, b=True, c=3)", "'keywords_only_method(1, true, 3)'");
 
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .update("mock", new Mock())
         .testIfErrorContains(
             "keywords_only_method() missing 1 required named argument: b",
             "mock.keywords_only_method(a=1, c=3)");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testEval("mock.keywords_only_method(a=1, c=3)", "'keywords_only_method(1, false, 3)'");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testIfErrorContains(
             "parameter 'b' is experimental and thus unavailable with the current "
@@ -179,13 +182,13 @@
   @Test
   public void testMixedParamsMethod() throws Exception {
     // def mixed_params_method(a, b, c = ?, d = ?)
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .update("mock", new Mock())
         .testEval(
             "mock.mixed_params_method(1, True, c=3, d=True)",
             "'mixed_params_method(1, true, 3, true)'");
 
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .update("mock", new Mock())
         .testIfErrorContains(
             // Missing named arguments (d) are not reported
@@ -194,18 +197,18 @@
             "mock.mixed_params_method(1, c=3)");
 
     // def mixed_params_method(a, b disabled = False, c disabled = 3, d = ?)
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testEval(
             "mock.mixed_params_method(1, d=True)", "'mixed_params_method(1, false, 3, true)'");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testIfErrorContains(
             "mixed_params_method() accepts no more than 1 positional argument but got 2",
             "mock.mixed_params_method(1, True, d=True)");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testIfErrorContains(
             "mixed_params_method() accepts no more than 1 positional argument but got 2",
@@ -214,23 +217,23 @@
 
   @Test
   public void testKeywordsMultipleFlags() throws Exception {
-    new Scenario("--experimental_build_setting_api=true", "--incompatible_no_attr_license=false")
+    ev.new Scenario("--experimental_build_setting_api=true", "--incompatible_no_attr_license=false")
         .update("mock", new Mock())
         .testEval(
             "mock.keywords_multiple_flags(a=42, b=True, c=0)",
             "'keywords_multiple_flags(42, true, 0)'");
 
-    new Scenario("--experimental_build_setting_api=true", "--incompatible_no_attr_license=false")
+    ev.new Scenario("--experimental_build_setting_api=true", "--incompatible_no_attr_license=false")
         .update("mock", new Mock())
         .testIfErrorContains(
             "keywords_multiple_flags() missing 2 required named arguments: b, c",
             "mock.keywords_multiple_flags(a=42)");
 
-    new Scenario("--experimental_build_setting_api=false", "--incompatible_no_attr_license=true")
+    ev.new Scenario("--experimental_build_setting_api=false", "--incompatible_no_attr_license=true")
         .update("mock", new Mock())
         .testEval("mock.keywords_multiple_flags(a=42)", "'keywords_multiple_flags(42, false, 3)'");
 
-    new Scenario("--experimental_build_setting_api=false", "--incompatible_no_attr_license=true")
+    ev.new Scenario("--experimental_build_setting_api=false", "--incompatible_no_attr_license=true")
         .update("mock", new Mock())
         .testIfErrorContains(
             "parameter 'b' is deprecated and will be removed soon. It may be "
@@ -243,26 +246,35 @@
     // This test uses an arbitrary experimental flag to verify this functionality. If this
     // experimental flag were to go away, this test may be updated to use any experimental flag.
     // The flag itself is unimportant to the test.
-    predeclare(
-        "GlobalSymbol",
-        FlagGuardedValue.onlyWhenExperimentalFlagIsTrue(
-            FlagIdentifier.EXPERIMENTAL_BUILD_SETTING_API, "foo"));
+
+    // clumsy way to predeclare
+    ev =
+        new EvaluationTestCase() {
+          @Override
+          protected Object newModuleHook(ImmutableMap.Builder<String, Object> predeclared) {
+            predeclared.put(
+                "GlobalSymbol",
+                FlagGuardedValue.onlyWhenExperimentalFlagIsTrue(
+                    FlagIdentifier.EXPERIMENTAL_BUILD_SETTING_API, "foo"));
+            return null; // no client data
+          }
+        };
 
     String errorMessage =
         "GlobalSymbol is experimental and thus unavailable with the current "
             + "flags. It may be enabled by setting --experimental_build_setting_api";
 
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .setUp("var = GlobalSymbol")
         .testLookup("var", "foo");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .testIfErrorContains(errorMessage, "var = GlobalSymbol");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .testIfErrorContains(errorMessage, "def my_function():", "  var = GlobalSymbol");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .setUp("GlobalSymbol = 'other'", "var = GlobalSymbol")
         .testLookup("var", "other");
   }
@@ -272,26 +284,34 @@
     // This test uses an arbitrary incompatible flag to verify this functionality. If this
     // incompatible flag were to go away, this test may be updated to use any incompatible flag.
     // The flag itself is unimportant to the test.
-    predeclare(
-        "GlobalSymbol",
-        FlagGuardedValue.onlyWhenIncompatibleFlagIsFalse(
-            FlagIdentifier.INCOMPATIBLE_LINKOPTS_TO_LINKLIBS, "foo"));
+
+    ev =
+        new EvaluationTestCase() {
+          @Override
+          protected Object newModuleHook(ImmutableMap.Builder<String, Object> predeclared) {
+            predeclared.put(
+                "GlobalSymbol",
+                FlagGuardedValue.onlyWhenIncompatibleFlagIsFalse(
+                    FlagIdentifier.INCOMPATIBLE_LINKOPTS_TO_LINKLIBS, "foo"));
+            return null; // no client data
+          }
+        };
 
     String errorMessage =
         "GlobalSymbol is deprecated and will be removed soon. It may be "
             + "temporarily re-enabled by setting --incompatible_linkopts_to_linklibs=false";
 
-    new Scenario("--incompatible_linkopts_to_linklibs=false")
+    ev.new Scenario("--incompatible_linkopts_to_linklibs=false")
         .setUp("var = GlobalSymbol")
         .testLookup("var", "foo");
 
-    new Scenario("--incompatible_linkopts_to_linklibs=true")
+    ev.new Scenario("--incompatible_linkopts_to_linklibs=true")
         .testIfErrorContains(errorMessage, "var = GlobalSymbol");
 
-    new Scenario("--incompatible_linkopts_to_linklibs=true")
+    ev.new Scenario("--incompatible_linkopts_to_linklibs=true")
         .testIfErrorContains(errorMessage, "def my_function():", "  var = GlobalSymbol");
 
-    new Scenario("--incompatible_linkopts_to_linklibs=true")
+    ev.new Scenario("--incompatible_linkopts_to_linklibs=true")
         .setUp("GlobalSymbol = 'other'", "var = GlobalSymbol")
         .testLookup("var", "other");
   }
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadDebuggingTest.java b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadDebuggingTest.java
index 22bff3b..3553cb9 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadDebuggingTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadDebuggingTest.java
@@ -18,6 +18,7 @@
 import static org.junit.Assert.assertThrows;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.devtools.build.lib.syntax.StarlarkThread.ReadyToPause;
 import com.google.devtools.build.lib.syntax.StarlarkThread.Stepping;
 import org.junit.Test;
@@ -30,22 +31,21 @@
 
   // TODO(adonovan): rewrite these tests at a higher level.
 
-  private static StarlarkThread newStarlarkThread() {
-    Mutability mutability = Mutability.create("test");
-    return StarlarkThread.builder(mutability).useDefaultSemantics().build();
+  private static StarlarkThread newThread() {
+    return new StarlarkThread(Mutability.create("test"), StarlarkSemantics.DEFAULT);
   }
 
-  // Executes the definition of a trivial function f in the specified thread,
-  // and returns the function value.
-  private static StarlarkFunction defineFunc(StarlarkThread thread) throws Exception {
-    Module module = thread.getGlobals();
-    EvalUtils.exec(ParserInput.fromLines("def f(): pass"), FileOptions.DEFAULT, module, thread);
-    return (StarlarkFunction) thread.getGlobals().lookup("f");
+  // Executes the definition of a trivial function f and returns the function value.
+  private static StarlarkFunction defineFunc() throws Exception {
+    Module module = Module.create();
+    EvalUtils.exec(
+        ParserInput.fromLines("def f(): pass"), FileOptions.DEFAULT, module, newThread());
+    return (StarlarkFunction) module.getGlobal("f");
   }
 
   @Test
   public void testListFramesEmptyStack() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
+    StarlarkThread thread = newThread();
     assertThat(Debug.getCallStack(thread)).isEmpty();
     assertThat(thread.getCallStack()).isEmpty();
   }
@@ -80,11 +80,8 @@
         };
 
     // Set up global environment.
-    StarlarkThread thread = newStarlarkThread();
-    Module module = thread.getGlobals();
-    module.put("a", 1);
-    module.put("b", 2);
-    module.put("f", f);
+    Module module =
+        Module.withPredeclared(StarlarkSemantics.DEFAULT, ImmutableMap.of("a", 1, "b", 2, "f", f));
 
     // Execute a small file that calls f.
     ParserInput input =
@@ -93,7 +90,7 @@
                 + "  f()\n"
                 + "g(4, 5, 6)",
             "main.star");
-    EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
+    EvalUtils.exec(input, FileOptions.DEFAULT, module, newThread());
 
     @SuppressWarnings("unchecked")
     ImmutableList<Debug.Frame> stack = (ImmutableList<Debug.Frame>) result[0];
@@ -127,10 +124,10 @@
 
   @Test
   public void testStepIntoFunction() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
+    StarlarkThread thread = newThread();
 
     ReadyToPause predicate = thread.stepControl(Stepping.INTO);
-    thread.push(defineFunc(thread));
+    thread.push(defineFunc());
 
     assertThat(predicate.test(thread)).isTrue();
   }
@@ -139,7 +136,7 @@
   public void testStepIntoFallsBackToStepOver() {
     // test that when stepping into, we'll fall back to stopping at the next statement in the
     // current frame
-    StarlarkThread thread = newStarlarkThread();
+    StarlarkThread thread = newThread();
 
     ReadyToPause predicate = thread.stepControl(Stepping.INTO);
 
@@ -149,8 +146,8 @@
   @Test
   public void testStepIntoFallsBackToStepOut() throws Exception {
     // test that when stepping into, we'll fall back to stopping when exiting the current frame
-    StarlarkThread thread = newStarlarkThread();
-    thread.push(defineFunc(thread));
+    StarlarkThread thread = newThread();
+    thread.push(defineFunc());
 
     ReadyToPause predicate = thread.stepControl(Stepping.INTO);
     thread.pop();
@@ -160,10 +157,10 @@
 
   @Test
   public void testStepOverFunction() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
+    StarlarkThread thread = newThread();
 
     ReadyToPause predicate = thread.stepControl(Stepping.OVER);
-    thread.push(defineFunc(thread));
+    thread.push(defineFunc());
 
     assertThat(predicate.test(thread)).isFalse();
     thread.pop();
@@ -173,8 +170,8 @@
   @Test
   public void testStepOverFallsBackToStepOut() throws Exception {
     // test that when stepping over, we'll fall back to stopping when exiting the current frame
-    StarlarkThread thread = newStarlarkThread();
-    thread.push(defineFunc(thread));
+    StarlarkThread thread = newThread();
+    thread.push(defineFunc());
 
     ReadyToPause predicate = thread.stepControl(Stepping.OVER);
     thread.pop();
@@ -184,8 +181,8 @@
 
   @Test
   public void testStepOutOfInnerFrame() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
-    thread.push(defineFunc(thread));
+    StarlarkThread thread = newThread();
+    thread.push(defineFunc());
 
     ReadyToPause predicate = thread.stepControl(Stepping.OUT);
 
@@ -196,47 +193,47 @@
 
   @Test
   public void testStepOutOfOutermostFrame() {
-    StarlarkThread thread = newStarlarkThread();
+    StarlarkThread thread = newThread();
 
     assertThat(thread.stepControl(Stepping.OUT)).isNull();
   }
 
   @Test
   public void testStepControlWithNoSteppingReturnsNull() {
-    StarlarkThread thread = newStarlarkThread();
+    StarlarkThread thread = newThread();
 
     assertThat(thread.stepControl(Stepping.NONE)).isNull();
   }
 
   @Test
   public void testEvaluateVariableInScope() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
-    Module module = thread.getGlobals();
-    module.put("a", 1);
+    Module module = Module.withPredeclared(StarlarkSemantics.DEFAULT, ImmutableMap.of("a", 1));
 
+    StarlarkThread thread = newThread();
     Object a = EvalUtils.exec(ParserInput.fromLines("a"), FileOptions.DEFAULT, module, thread);
     assertThat(a).isEqualTo(1);
   }
 
   @Test
   public void testEvaluateVariableNotInScopeFails() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
-    Module module = thread.getGlobals();
-    module.put("a", 1);
+    Module module = Module.create();
 
     SyntaxError.Exception e =
         assertThrows(
             SyntaxError.Exception.class,
-            () -> EvalUtils.exec(ParserInput.fromLines("b"), FileOptions.DEFAULT, module, thread));
+            () ->
+                EvalUtils.exec(
+                    ParserInput.fromLines("b"), FileOptions.DEFAULT, module, newThread()));
 
     assertThat(e).hasMessageThat().isEqualTo("name 'b' is not defined");
   }
 
   @Test
   public void testEvaluateExpressionOnVariableInScope() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
-    Module module = thread.getGlobals();
-    module.put("a", "string");
+    StarlarkThread thread = newThread();
+    Module module =
+        Module.withPredeclared(
+            StarlarkSemantics.DEFAULT, /*predeclared=*/ ImmutableMap.of("a", "string"));
 
     assertThat(
             EvalUtils.exec(
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadTest.java b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadTest.java
index b688508..41557ef 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static org.junit.Assert.assertThrows;
 
-import com.google.common.collect.Sets;
 import com.google.devtools.build.lib.syntax.util.EvaluationTestCase;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -74,128 +73,37 @@
   }
 
   @Test
-  public void testBuilderRequiresSemantics() throws Exception {
-    try (Mutability mut = Mutability.create("test")) {
-      IllegalArgumentException expected =
-          assertThrows(IllegalArgumentException.class, () -> StarlarkThread.builder(mut).build());
-      assertThat(expected)
-          .hasMessageThat()
-          .contains("must call either setSemantics or useDefaultSemantics");
-    }
-  }
-
-  @Test
-  public void testGetVariableNames() throws Exception {
-    StarlarkThread thread;
-    try (Mutability mut = Mutability.create("outer")) {
-      thread =
-          StarlarkThread.builder(mut)
-              .useDefaultSemantics()
-              .setGlobals(Module.createForBuiltins(Starlark.UNIVERSE))
-              .build();
-      thread.getGlobals().put("foo", "bar");
-      thread.getGlobals().put("wiz", 3);
-    }
-
-    assertThat(thread.getVariableNames())
-        .isEqualTo(
-            Sets.newHashSet(
-                "foo",
-                "wiz",
-                "False",
-                "None",
-                "True",
-                "all",
-                "any",
-                "bool",
-                "dict",
-                "dir",
-                "enumerate",
-                "fail",
-                "getattr",
-                "hasattr",
-                "hash",
-                "int",
-                "len",
-                "list",
-                "max",
-                "min",
-                "print",
-                "range",
-                "repr",
-                "reversed",
-                "sorted",
-                "str",
-                "tuple",
-                "type",
-                "zip"));
-  }
-
-  @Test
   public void testBindToNullThrowsException() throws Exception {
     NullPointerException e =
         assertThrows(NullPointerException.class, () -> ev.update("some_name", null));
-    assertThat(e).hasMessageThat().isEqualTo("Module.put(some_name, null)");
-  }
-
-  @Test
-  public void testFrozen() throws Exception {
-    Module module;
-    try (Mutability mutability = Mutability.create("testFrozen")) {
-      // TODO(adonovan): make it simpler to construct a module without a thread,
-      // and move this test to ModuleTest.
-      StarlarkThread thread =
-          StarlarkThread.builder(mutability)
-              .useDefaultSemantics()
-              .setGlobals(Module.createForBuiltins(Starlark.UNIVERSE))
-              .build();
-      module = thread.getGlobals();
-      module.put("x", 1);
-      assertThat(module.lookup("x")).isEqualTo(1);
-      module.put("y", 2);
-      assertThat(module.lookup("y")).isEqualTo(2);
-      assertThat(module.lookup("x")).isEqualTo(1);
-      module.put("x", 3);
-      assertThat(module.lookup("x")).isEqualTo(3);
-    }
-
-    // This update to an existing variable should fail because the environment was frozen.
-    EvalException ex = assertThrows(EvalException.class, () -> module.put("x", 4));
-    assertThat(ex).hasMessageThat().isEqualTo("trying to mutate a frozen module");
-
-    // This update to a new variable should also fail because the environment was frozen.
-    ex = assertThrows(EvalException.class, () -> module.put("newvar", 5));
-    assertThat(ex).hasMessageThat().isEqualTo("trying to mutate a frozen module");
+    assertThat(e).hasMessageThat().isEqualTo("Module.setGlobal(some_name, null)");
   }
 
   @Test
   public void testBuiltinsCanBeShadowed() throws Exception {
+    Module module = Module.create();
     try (Mutability mu = Mutability.create("test")) {
-      StarlarkThread thread = StarlarkThread.builder(mu).useDefaultSemantics().build();
-      EvalUtils.exec(
-          ParserInput.fromLines("True = 123"), FileOptions.DEFAULT, thread.getGlobals(), thread);
-      assertThat(thread.getGlobals().lookup("True")).isEqualTo(123);
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      EvalUtils.exec(ParserInput.fromLines("True = 123"), FileOptions.DEFAULT, module, thread);
     }
+    assertThat(module.getGlobal("True")).isEqualTo(123);
   }
 
   @Test
   public void testVariableIsReferencedBeforeAssignment() throws Exception {
-    try (Mutability mu = Mutability.create("test")) {
-      StarlarkThread thread = StarlarkThread.builder(mu).useDefaultSemantics().build();
-      Module module = thread.getGlobals();
-      module.put("global_var", 666);
-      EvalUtils.exec(
-          ParserInput.fromLines(
-              "def foo(x): x += global_var; global_var = 36; return x", //
-              "foo(1)"),
-          FileOptions.DEFAULT,
-          module,
-          thread);
-      throw new AssertionError("failed to fail");
-    } catch (EvalExceptionWithStackTrace e) {
-      assertThat(e)
-          .hasMessageThat()
-          .contains("local variable 'global_var' is referenced before assignment.");
-    }
+    ev.new Scenario()
+        .testIfErrorContains(
+            "local variable 'y' is referenced before assignment",
+            "y = 1", // bind => y is global
+            "def foo(x):",
+            "  x += y", // fwd ref to local y
+            "  y = 2", // binding => y is local
+            "  return x",
+            "foo(1)");
+    ev.new Scenario()
+        .testIfErrorContains(
+            "global variable 'len' is referenced before assignment",
+            "print(len)", // fwd ref to global len
+            "len = 1"); // binding => len is local
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/util/EvaluationTestCase.java b/src/test/java/com/google/devtools/build/lib/syntax/util/EvaluationTestCase.java
index 0203bfe..b1c56bd 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/util/EvaluationTestCase.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/util/EvaluationTestCase.java
@@ -36,10 +36,8 @@
 import com.google.devtools.build.lib.syntax.SyntaxError;
 import com.google.devtools.common.options.Options;
 import com.google.devtools.common.options.OptionsParsingException;
-import java.util.HashMap;
 import java.util.LinkedList;
 import java.util.List;
-import java.util.Map;
 
 /** Helper class for tests that evaluate Starlark code. */
 // TODO(adonovan): stop extending this class. Prefer composition over inheritance.
@@ -54,58 +52,40 @@
 // predeclared env + semantics.
 // For the most part, the predeclared env doesn't vary across a suite,
 // so it could be a constructor parameter.
+//
+// TODO(adonovan): this helper class might be somewhat handy for testing core Starlark, but its
+// widespread use in tests of Bazel features greatly hinders the improvement of Bazel's loading
+// phase. The existence of tests based on this class forces Bazel to continue support scenarios in
+// which the test creates the environment, the threads, and so on, when these should be
+// implemenation details of the loading phase. Instead, the lib.packages should present an API in
+// which the client provides files, flags, and arguments like a command-line tool, and all our tests
+// should be ported to use that API.
 public class EvaluationTestCase {
   private EventCollectionApparatus eventCollectionApparatus =
       new EventCollectionApparatus(EventKind.ALL_EVENTS);
 
-  private final Map<String, Object> extraPredeclared = new HashMap<>();
-  private StarlarkThread thread = newStarlarkThread(StarlarkSemantics.DEFAULT);
-
-  // Adds a binding to the predeclared environment.
-  protected final void predeclare(String name, Object value) {
-    extraPredeclared.put(name, value);
-  }
+  private StarlarkSemantics semantics = StarlarkSemantics.DEFAULT;
+  private StarlarkThread thread = null; // created lazily by getStarlarkThread
+  private Module module = null; // created lazily by getModule
 
   /**
-   * Returns a new thread using the semantics set by setSemantics(), the predeclared environment of
-   * StarlarkModules and prior calls to predeclared(), and a new mutability. Overridden by
-   * subclasses.
-   */
-  // TODO(adonovan): stop using inheritance.
-  protected StarlarkThread newStarlarkThread(StarlarkSemantics semantics) {
-    ImmutableMap.Builder<String, Object> envBuilder = ImmutableMap.builder();
-    StarlarkModules.addStarlarkGlobalsToBuilder(envBuilder); // TODO(adonovan): break bad dependency
-    envBuilder.putAll(extraPredeclared);
-
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test"))
-            .setGlobals(Module.createForBuiltins(envBuilder.build()))
-            .setSemantics(semantics)
-            .build();
-    thread.setPrintHandler(Event.makeDebugPrintHandler(getEventHandler()));
-    return thread;
-  }
-
-  /**
-   * Parses the semantics flags and updates the semantics used for subsequent evaluations. Also
-   * reinitializes the thread.
+   * Parses the semantics flags and updates the semantics used to filter predeclared bindings, and
+   * carried by subsequently created threads. Causes a new StarlarkThread and Module to be created
+   * when next needed.
    */
   public final void setSemantics(String... options) throws OptionsParsingException {
-    StarlarkSemantics semantics =
+    this.semantics =
         Options.parse(StarlarkSemanticsOptions.class, options).getOptions().toStarlarkSemantics();
 
-    // Re-initialize the thread with the new semantics.
-    this.thread = newStarlarkThread(semantics);
+    // Re-initialize the thread and module with the new semantics when needed.
+    this.thread = null;
+    this.module = null;
   }
 
   public ExtendedEventHandler getEventHandler() {
     return eventCollectionApparatus.reporter();
   }
 
-  public StarlarkThread getStarlarkThread() {
-    return thread;
-  }
-
   // TODO(adonovan): don't let subclasses inherit vaguely specified "helpers".
   // Separate all the tests clearly into tests of the scanner, parser, resolver,
   // and evaluation.
@@ -115,28 +95,65 @@
     return Expression.parse(ParserInput.fromLines(lines));
   }
 
-  /** Updates a binding in the module associated with the thread. */
+  /** Updates a global binding in the module. */
+  // TODO(adonovan): rename setGlobal.
   public EvaluationTestCase update(String varname, Object value) throws Exception {
-    thread.getGlobals().put(varname, value);
+    getModule().setGlobal(varname, value);
     return this;
   }
 
-  /** Returns the value of a binding in the module associated with the thread. */
+  /** Returns the value of a global binding in the module. */
+  // TODO(adonovan): rename getGlobal.
   public Object lookup(String varname) throws Exception {
-    return thread.getGlobals().lookup(varname);
+    return getModule().getGlobal(varname);
   }
 
   /** Joins the lines, parses them as an expression, and evaluates it. */
   public final Object eval(String... lines) throws Exception {
     ParserInput input = ParserInput.fromLines(lines);
-    return EvalUtils.eval(input, FileOptions.DEFAULT, thread.getGlobals(), thread);
+    return EvalUtils.eval(input, FileOptions.DEFAULT, getModule(), getStarlarkThread());
   }
 
   /** Joins the lines, parses them as a file, and executes it. */
   public final void exec(String... lines)
       throws SyntaxError.Exception, EvalException, InterruptedException {
     ParserInput input = ParserInput.fromLines(lines);
-    EvalUtils.exec(input, FileOptions.DEFAULT, thread.getGlobals(), thread);
+    EvalUtils.exec(input, FileOptions.DEFAULT, getModule(), getStarlarkThread());
+  }
+
+  // A hook for subclasses to alter a newly created thread,
+  // e.g. by inserting thread-local values.
+  protected void newThreadHook(StarlarkThread thread) {}
+
+  // A hook for subclasses to alter the created module.
+  // Implementations may add to the predeclared environment,
+  // and return the module's client data value.
+  protected Object newModuleHook(ImmutableMap.Builder<String, Object> predeclared) {
+    StarlarkModules.addStarlarkGlobalsToBuilder(
+        predeclared); // TODO(adonovan): break bad dependency
+    return null; // no client data
+  }
+
+  public StarlarkThread getStarlarkThread() {
+    if (this.thread == null) {
+      Mutability mu = Mutability.create("test");
+      StarlarkThread thread = new StarlarkThread(mu, semantics);
+      thread.setPrintHandler(Event.makeDebugPrintHandler(getEventHandler()));
+      newThreadHook(thread);
+      this.thread = thread;
+    }
+    return this.thread;
+  }
+
+  public Module getModule() {
+    if (this.module == null) {
+      ImmutableMap.Builder<String, Object> predeclared = ImmutableMap.builder();
+      Object clientData = newModuleHook(predeclared);
+      Module module = Module.withPredeclared(semantics, predeclared.build());
+      module.setClientData(clientData);
+      this.module = module;
+    }
+    return this.module;
   }
 
   public void checkEvalError(String msg, String... input) throws Exception {
@@ -286,7 +303,7 @@
      *
      * @param exactMatch whether the error message must be identical to the expected error.
      */
-    protected Testable errorTestable(
+    private Testable errorTestable(
         final boolean exactMatch, final String error, final String... lines) {
       return new Testable() {
         @Override
@@ -304,7 +321,7 @@
      * Creates a Testable that checks whether the value of the expression is a sequence containing
      * the expected elements.
      */
-    protected Testable collectionTestable(final String src, final Object... expected) {
+    private Testable collectionTestable(final String src, final Object... expected) {
       return new Testable() {
         @Override
         public void run() throws Exception {
@@ -322,7 +339,7 @@
      * @param expectedIsExpression Signals whether {@code expected} is an object or an expression
      * @return An instance of Testable that runs the comparison
      */
-    protected Testable createComparisonTestable(
+    private Testable createComparisonTestable(
         final String src, final Object expected, final boolean expectedIsExpression) {
       return new Testable() {
         @Override
@@ -344,11 +361,12 @@
     /**
      * Creates a Testable that looks up the given variable and compares its value to the expected
      * value
+     *
      * @param name
      * @param expected
      * @return An instance of Testable that does both lookup and comparison
      */
-    protected Testable createLookUpTestable(final String name, final Object expected) {
+    private Testable createLookUpTestable(final String name, final Object expected) {
       return new Testable() {
         @Override
         public void run() throws Exception {