Allow an optional index of dep -> class to be created (#1492)

When the index is generated, the information that used to be in the lock file is moved to the index. This will reduce the size of the lock file.
diff --git a/MODULE.bazel b/MODULE.bazel
index fad546c..ca9ae99 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -745,6 +745,7 @@
         "org.slf4j:slf4j-log4j12:2.0.0",
     ],
     generate_compat_repositories = True,
+    index_file = "//tests/custom_maven_install:regression_testing_gradle_index.json",
     lock_file = "//tests/custom_maven_install:regression_testing_gradle_install.json",
     repositories = [
         "https://repo1.maven.org/maven2",
diff --git a/docs/api.md b/docs/api.md
index de01aef..c0fbf1e 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -230,8 +230,8 @@
 | <a id="maven_install-additional_netrc_lines"></a>additional_netrc_lines |  Additional lines prepended to the netrc file used by `http_file` (with `maven_install_json` only).   |  `[]` |
 | <a id="maven_install-use_credentials_from_home_netrc_file"></a>use_credentials_from_home_netrc_file |  Whether to pass machine login credentials from the ~/.netrc file to coursier.   |  `False` |
 | <a id="maven_install-fail_if_repin_required"></a>fail_if_repin_required |  Whether to fail the build if the required maven artifacts have been changed but not repinned. Requires the `maven_install_json` to have been set.   |  `True` |
-| <a id="maven_install-use_starlark_android_rules"></a>use_starlark_android_rules |  Whether to use the native or Starlark version of the Android rules. Default is False.   |  `False` |
-| <a id="maven_install-aar_import_bzl_label"></a>aar_import_bzl_label |  The label (as a string) to use to import aar_import from. This is usually needed only if the top-level workspace file does not use the typical default repository name to import the Android Starlark rules. Default is "@build_bazel_rules_android//rules:rules.bzl".   |  `"@build_bazel_rules_android//android:rules.bzl"` |
+| <a id="maven_install-use_starlark_android_rules"></a>use_starlark_android_rules |  Whether to use the native or Starlark version of the Android rules. Default is False if the running version of Bazel supports native aar_import. If the running version of Bazel does not support native aar_import, this parameter is ignored and the Starlark Android rules is used.   |  `False` |
+| <a id="maven_install-aar_import_bzl_label"></a>aar_import_bzl_label |  The label (as a string) to use to import aar_import from. This is usually needed only if the top-level workspace file does not use the typical default repository name to import the Android Starlark rules. Default is "@rules_android//rules:rules.bzl".   |  `"@rules_android//rules:rules.bzl"` |
 | <a id="maven_install-duplicate_version_warning"></a>duplicate_version_warning |  What to do if an artifact is specified multiple times. If "error" then fail the build, if "warn" then print a message and continue, if "none" then do nothing. The default is "warn".   |  `"warn"` |
 | <a id="maven_install-repin_instructions"></a>repin_instructions |  Instructions to re-pin dependencies in your repository. Will be shown when re-pinning is required.   |  `None` |
 | <a id="maven_install-ignore_empty_files"></a>ignore_empty_files |  Treat jars that are empty as if they were not found.   |  `False` |
diff --git a/docs/bzlmod-api.md b/docs/bzlmod-api.md
index cebc65d..e4bcead 100644
--- a/docs/bzlmod-api.md
+++ b/docs/bzlmod-api.md
@@ -13,12 +13,13 @@
                <a href="#maven.artifact-testonly">testonly</a>, <a href="#maven.artifact-version">version</a>)
 maven.from_toml(<a href="#maven.from_toml-name">name</a>, <a href="#maven.from_toml-bom_modules">bom_modules</a>, <a href="#maven.from_toml-libs_versions_toml">libs_versions_toml</a>)
 maven.install(<a href="#maven.install-name">name</a>, <a href="#maven.install-aar_import_bzl_label">aar_import_bzl_label</a>, <a href="#maven.install-additional_coursier_options">additional_coursier_options</a>, <a href="#maven.install-additional_netrc_lines">additional_netrc_lines</a>,
-              <a href="#maven.install-artifacts">artifacts</a>, <a href="#maven.install-boms">boms</a>, <a href="#maven.install-duplicate_version_warning">duplicate_version_warning</a>, <a href="#maven.install-excluded_artifacts">excluded_artifacts</a>, <a href="#maven.install-exclusions">exclusions</a>,
-              <a href="#maven.install-fail_if_repin_required">fail_if_repin_required</a>, <a href="#maven.install-fail_on_missing_checksum">fail_on_missing_checksum</a>, <a href="#maven.install-fetch_javadoc">fetch_javadoc</a>, <a href="#maven.install-fetch_sources">fetch_sources</a>,
-              <a href="#maven.install-generate_compat_repositories">generate_compat_repositories</a>, <a href="#maven.install-ignore_empty_files">ignore_empty_files</a>, <a href="#maven.install-known_contributing_modules">known_contributing_modules</a>, <a href="#maven.install-lock_file">lock_file</a>,
-              <a href="#maven.install-repin_instructions">repin_instructions</a>, <a href="#maven.install-repositories">repositories</a>, <a href="#maven.install-resolve_timeout">resolve_timeout</a>, <a href="#maven.install-resolver">resolver</a>, <a href="#maven.install-strict_visibility">strict_visibility</a>,
-              <a href="#maven.install-strict_visibility_value">strict_visibility_value</a>, <a href="#maven.install-use_credentials_from_home_netrc_file">use_credentials_from_home_netrc_file</a>,
-              <a href="#maven.install-use_starlark_android_rules">use_starlark_android_rules</a>, <a href="#maven.install-version_conflict_policy">version_conflict_policy</a>)
+              <a href="#maven.install-artifacts">artifacts</a>, <a href="#maven.install-boms">boms</a>, <a href="#maven.install-dependency_index">dependency_index</a>, <a href="#maven.install-duplicate_version_warning">duplicate_version_warning</a>, <a href="#maven.install-excluded_artifacts">excluded_artifacts</a>,
+              <a href="#maven.install-exclusions">exclusions</a>, <a href="#maven.install-fail_if_repin_required">fail_if_repin_required</a>, <a href="#maven.install-fail_on_missing_checksum">fail_on_missing_checksum</a>, <a href="#maven.install-fetch_javadoc">fetch_javadoc</a>,
+              <a href="#maven.install-fetch_sources">fetch_sources</a>, <a href="#maven.install-generate_compat_repositories">generate_compat_repositories</a>, <a href="#maven.install-ignore_empty_files">ignore_empty_files</a>,
+              <a href="#maven.install-known_contributing_modules">known_contributing_modules</a>, <a href="#maven.install-lock_file">lock_file</a>, <a href="#maven.install-repin_instructions">repin_instructions</a>, <a href="#maven.install-repositories">repositories</a>,
+              <a href="#maven.install-resolve_timeout">resolve_timeout</a>, <a href="#maven.install-resolver">resolver</a>, <a href="#maven.install-strict_visibility">strict_visibility</a>, <a href="#maven.install-strict_visibility_value">strict_visibility_value</a>,
+              <a href="#maven.install-use_credentials_from_home_netrc_file">use_credentials_from_home_netrc_file</a>, <a href="#maven.install-use_starlark_android_rules">use_starlark_android_rules</a>,
+              <a href="#maven.install-version_conflict_policy">version_conflict_policy</a>)
 maven.override(<a href="#maven.override-name">name</a>, <a href="#maven.override-coordinates">coordinates</a>, <a href="#maven.override-target">target</a>)
 </pre>
 
@@ -88,11 +89,12 @@
 | Name  | Description | Type | Mandatory | Default |
 | :------------- | :------------- | :------------- | :------------- | :------------- |
 | <a id="maven.install-name"></a>name |  -   | <a href="https://bazel.build/concepts/labels#target-names">Name</a> | optional |  `"maven"`  |
-| <a id="maven.install-aar_import_bzl_label"></a>aar_import_bzl_label |  The label (as a string) to use to import aar_import from   | String | optional |  `"@build_bazel_rules_android//android:rules.bzl"`  |
+| <a id="maven.install-aar_import_bzl_label"></a>aar_import_bzl_label |  The label (as a string) to use to import aar_import from   | String | optional |  `"@rules_android//rules:rules.bzl"`  |
 | <a id="maven.install-additional_coursier_options"></a>additional_coursier_options |  Additional options that will be passed to coursier.   | List of strings | optional |  `[]`  |
 | <a id="maven.install-additional_netrc_lines"></a>additional_netrc_lines |  Additional lines prepended to the netrc file used by `http_file` (with `maven_install_json` only).   | List of strings | optional |  `[]`  |
 | <a id="maven.install-artifacts"></a>artifacts |  Maven artifact tuples, in `artifactId:groupId:version` format   | List of strings | optional |  `[]`  |
 | <a id="maven.install-boms"></a>boms |  Maven BOM tuples, in `artifactId:groupId:version` format   | List of strings | optional |  `[]`  |
+| <a id="maven.install-dependency_index"></a>dependency_index |  If present, when dependencies are resolved this file will contain information the java gazelle plugin can use to more accurately construct build files. The default name should be `maven_index.json`.   | <a href="https://bazel.build/concepts/labels">Label</a> | optional |  `None`  |
 | <a id="maven.install-duplicate_version_warning"></a>duplicate_version_warning |  What to do if there are duplicate artifacts<br><br>If "error", then print a message and fail the build. If "warn", then print a warning and continue. If "none", then do nothing.   | String | optional |  `"warn"`  |
 | <a id="maven.install-excluded_artifacts"></a>excluded_artifacts |  Artifacts to exclude, in `artifactId:groupId` format. Only used on unpinned installs   | List of strings | optional |  `[]`  |
 | <a id="maven.install-exclusions"></a>exclusions |  Maven artifact tuples, in `artifactId:groupId` format   | List of strings | optional |  `[]`  |
diff --git a/private/extensions/maven.bzl b/private/extensions/maven.bzl
index 2edc587..c42a051 100644
--- a/private/extensions/maven.bzl
+++ b/private/extensions/maven.bzl
@@ -91,6 +91,7 @@
         ),
         "fail_if_repin_required": attr.bool(doc = "Whether to fail the build if the maven_artifact inputs have changed but the lock file has not been repinned.", default = True),
         "lock_file": attr.label(),
+        "index_file": attr.label(doc = "If present, when dependencies are resolved this file will contain information the java gazelle plugin can use to more accurately construct build files. The default name should be `maven_index.json`."),
         "repositories": attr.string_list(default = DEFAULT_REPOSITORIES),
         "generate_compat_repositories": attr.bool(
             doc = "Additionally generate repository aliases in a .bzl file for all JAR artifacts. For example, `@maven//:com_google_guava_guava` can also be referenced as `@com_google_guava_guava//jar`.",
@@ -649,6 +650,7 @@
 
                 # We will always have a lock file, so this is fine
                 repo_to_lock_file[install.name] = [install.lock_file]
+                repo["dependency_index"] = install.index_file
                 repo["fail_if_repin_required"] = install.fail_if_repin_required
                 repo["fail_on_missing_checksum"] = install.fail_on_missing_checksum
                 repo["fetch_javadoc"] = install.fetch_javadoc
@@ -714,6 +716,7 @@
                 strict_visibility_value = repo.get("strict_visibility_value"),
                 use_credentials_from_home_netrc_file = repo.get("use_credentials_from_home_netrc_file"),
                 maven_install_json = repo.get("lock_file"),
+                dependency_index = repo.get("dependency_index"),
                 resolve_timeout = repo.get("resolve_timeout"),
                 use_starlark_android_rules = repo.get("use_starlark_android_rules"),
                 aar_import_bzl_label = repo.get("aar_import_bzl_label"),
@@ -770,6 +773,7 @@
                 resolver = repo.get("resolver", _DEFAULT_RESOLVER),
                 generate_compat_repositories = False,
                 maven_install_json = repo.get("lock_file"),
+                dependency_index = repo.get("dependency_index"),
                 override_targets = overrides.get(name),
                 override_target_visibilities = override_visibilities.get(name, {}),
                 strict_visibility = repo.get("strict_visibility"),
diff --git a/private/rules/coursier.bzl b/private/rules/coursier.bzl
index 73d54cf..f6abf5c 100644
--- a/private/rules/coursier.bzl
+++ b/private/rules/coursier.bzl
@@ -124,6 +124,7 @@
     fetch_sources = {fetch_sources},
     fetch_javadocs = {fetch_javadocs},
     lock_file = {lock_file},
+    dependency_index = {dependency_index},
     jvm_flags = {jvm_flags},
     visibility = ["//visibility:public"],
     resolver = {resolver},
@@ -812,6 +813,15 @@
         else:
             fail("Unknown resolver")
 
+        dependency_index_location = None
+        if repository_ctx.attr.dependency_index:
+            dep_index_package_path = repository_ctx.attr.dependency_index.package
+            dep_index_file_name = repository_ctx.attr.dependency_index.name
+            if dep_index_package_path == "":
+                dependency_index_location = dep_index_file_name
+            else:
+                dependency_index_location = "/".join([dep_index_package_path, dep_index_file_name])
+
         return _IN_REPO_PIN.format(
             boms = repr(repository_ctx.attr.boms),
             artifacts = repr(repository_ctx.attr.artifacts),
@@ -821,6 +831,7 @@
             fetch_sources = repr(repository_ctx.attr.fetch_sources),
             fetch_javadocs = repr(repository_ctx.attr.fetch_javadoc),
             lock_file = repr(lock_file_location),
+            dependency_index = repr(dependency_index_location),
             resolver = repr(str(resolver_target)),
         )
 
@@ -1553,6 +1564,7 @@
         "fetch_javadoc": attr.bool(default = False),
         "generate_compat_repositories": attr.bool(default = False),  # generate a compatible layer with repositories for each artifact
         "maven_install_json": attr.label(allow_single_file = True),
+        "dependency_index": attr.label(allow_single_file = True),
         "override_targets": attr.string_dict(default = {}),
         "override_target_visibilities": attr.string_list_dict(default = {}),
         "strict_visibility": attr.bool(
@@ -1624,6 +1636,7 @@
             ],
         ),
         "maven_install_json": attr.label(allow_single_file = True),
+        "dependency_index": attr.label(allow_single_file = True),
         "override_targets": attr.string_dict(default = {}),
         "override_target_visibilities": attr.string_list_dict(default = {}),
         "strict_visibility": attr.bool(
diff --git a/private/rules/pin_dependencies.bzl b/private/rules/pin_dependencies.bzl
index e500359..4029902 100644
--- a/private/rules/pin_dependencies.bzl
+++ b/private/rules/pin_dependencies.bzl
@@ -15,7 +15,7 @@
 
 _TEMPLATE = """#!/usr/bin/env bash
 
-{resolver_cmd} --jvm_flags={jvm_flags} --argsfile {config} --input_hash '{input_hash}' --output {output}
+{resolver_cmd} --jvm_flags={jvm_flags} --argsfile {config} --input_hash '{input_hash}' --output {output}{dependency_index_output}
 """
 
 def _stringify_exclusions(exclusions):
@@ -70,6 +70,10 @@
         excluded_artifacts = ctx.attr.excluded_artifacts,
     )
 
+    dependency_index_output = ""
+    if ctx.attr.dependency_index:
+        dependency_index_output = " --dependency-index-output $BUILD_WORKSPACE_DIRECTORY/" + ctx.attr.dependency_index
+
     script = ctx.actions.declare_file(ctx.label.name)
     ctx.actions.write(
         script,
@@ -78,6 +82,7 @@
             input_hash = input_hash[0],
             resolver_cmd = ctx.executable.resolver.short_path,
             output = "$BUILD_WORKSPACE_DIRECTORY/" + ctx.attr.lock_file,
+            dependency_index_output = dependency_index_output,
             jvm_flags = ctx.attr.jvm_flags,
         ),
         is_executable = True,
@@ -113,6 +118,9 @@
             doc = "Location of the generated lock file",
             mandatory = True,
         ),
+        "dependency_index": attr.string(
+            doc = "Location of the generated dependency index file",
+        ),
         "jvm_flags": attr.string(
             doc = "JVM flags to pass to resolver",
         ),
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/coursier/LockFileConverter.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/coursier/LockFileConverter.java
index 5899e05..3e6fa98 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/coursier/LockFileConverter.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/coursier/LockFileConverter.java
@@ -100,7 +100,7 @@
     Set<DependencyInfo> infos = converter.getDependencies();
     Set<Conflict> conflicts = converter.getConflicts();
 
-    Map<String, Object> rendered = new V2LockFile(repositories, infos, conflicts).render();
+    Map<String, Object> rendered = new V2LockFile(repositories, infos, conflicts, true).render();
 
     String converted =
         new GsonBuilder().setPrettyPrinting().serializeNulls().create().toJson(rendered);
@@ -213,6 +213,7 @@
               Optional.ofNullable((String) coursierDep.get("sha256")),
               directDeps,
               packages,
+              Set.of(),
               services));
     }
 
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/IndexJar.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/IndexJar.java
index c098656..f147723 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/IndexJar.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/IndexJar.java
@@ -84,6 +84,7 @@
 
   public PerJarIndexResults index(Path path) throws IOException {
     SortedSet<String> packages = new TreeSet<>();
+    SortedSet<String> classes = new TreeSet<>();
     SortedMap<String, SortedSet<String>> serviceImplementations = new TreeMap<>();
     try (InputStream fis = new BufferedInputStream(Files.newInputStream(path));
         ZipInputStream zis = new ZipInputStream(fis)) {
@@ -103,12 +104,17 @@
               || entry.getName().endsWith("/module-info.class")) {
             continue;
           }
+          // Skip inner classes, anonymous classes, and local classes (contain $)
+          if (isInnerClass(entry.getName())) {
+            continue;
+          }
           packages.add(extractPackageName(entry.getName()));
+          classes.add(extractClassName(entry.getName()));
         }
       } catch (ZipException e) {
         System.err.printf("Caught ZipException: %s%n", e);
       }
-      return new PerJarIndexResults(packages, serviceImplementations);
+      return new PerJarIndexResults(packages, classes, serviceImplementations);
     }
   }
 
@@ -151,7 +157,31 @@
     return Arrays.stream(parts).skip(skip).limit(limit).collect(Collectors.joining("."));
   }
 
+  private String extractClassName(String zipEntryName) {
+    String[] parts = zipEntryName.split("/");
+    int skip = 0;
+    // As per https://docs.oracle.com/en/java/javase/13/docs/specs/jar/jar.html
+    if (parts.length > 3
+        && "META-INF".equals(parts[0])
+        && "versions".equals(parts[1])
+        && isNumericVersion(parts[2])) {
+      skip = 3;
+    }
+
+    // Remove .class suffix and join with dots
+    String className = Arrays.stream(parts).skip(skip).collect(Collectors.joining("."));
+    return className.substring(0, className.length() - ".class".length());
+  }
+
   private boolean isNumericVersion(String part) {
     return IS_NUMERIC_VERSION.test(part);
   }
+
+  private boolean isInnerClass(String zipEntryName) {
+    // Inner classes, anonymous classes, and local classes all contain '$' in the class file name
+    // Examples: Outer$Inner.class, Outer$1.class, Outer$1LocalClass.class
+    int lastSlash = zipEntryName.lastIndexOf('/');
+    String fileName = lastSlash == -1 ? zipEntryName : zipEntryName.substring(lastSlash + 1);
+    return fileName.contains("$");
+  }
 }
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/PerJarIndexResults.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/PerJarIndexResults.java
index 6c96a8f..13a7a53 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/PerJarIndexResults.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/PerJarIndexResults.java
@@ -5,11 +5,15 @@
 
 public class PerJarIndexResults {
   private final SortedSet<String> packages;
+  private final SortedSet<String> classes;
   private final SortedMap<String, SortedSet<String>> serviceImplementations;
 
   public PerJarIndexResults(
-      SortedSet<String> packages, SortedMap<String, SortedSet<String>> serviceImplementations) {
+      SortedSet<String> packages,
+      SortedSet<String> classes,
+      SortedMap<String, SortedSet<String>> serviceImplementations) {
     this.packages = packages;
+    this.classes = classes;
     this.serviceImplementations = serviceImplementations;
   }
 
@@ -17,6 +21,10 @@
     return this.packages;
   }
 
+  public SortedSet<String> getClasses() {
+    return this.classes;
+  }
+
   public SortedMap<String, SortedSet<String>> getServiceImplementations() {
     return this.serviceImplementations;
   }
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/DependencyInfo.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/DependencyInfo.java
index 7bdd874..be7bab1 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/DependencyInfo.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/DependencyInfo.java
@@ -34,6 +34,7 @@
   private final Optional<String> sha256;
   private final Set<Coordinates> dependencies;
   private final Set<String> packages;
+  private final Set<String> classes;
   private final SortedMap<String, SortedSet<String>> services;
 
   public DependencyInfo(
@@ -43,6 +44,7 @@
       Optional<String> sha256,
       Set<Coordinates> dependencies,
       Set<String> packages,
+      Set<String> classes,
       SortedMap<String, SortedSet<String>> services) {
     this.coordinates = coordinates;
     this.repos = ImmutableSet.copyOf(repos);
@@ -51,6 +53,7 @@
     this.dependencies = ImmutableSet.copyOf(new TreeSet<>(dependencies));
 
     this.packages = ImmutableSet.copyOf(new TreeSet<>(packages));
+    this.classes = ImmutableSet.copyOf(new TreeSet<>(classes));
     this.services = services;
   }
 
@@ -74,6 +77,10 @@
     return packages;
   }
 
+  public Set<String> getClasses() {
+    return classes;
+  }
+
   public Optional<Path> getPath() {
     return path;
   }
@@ -104,11 +111,12 @@
         && Objects.equals(sha256, that.sha256)
         && Objects.equals(dependencies, that.dependencies)
         && Objects.equals(packages, that.packages)
+        && Objects.equals(classes, that.classes)
         && Objects.equals(services, that.services);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(coordinates, sha256, dependencies, packages, services);
+    return Objects.hash(coordinates, sha256, dependencies, packages, classes, services);
   }
 }
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/AbstractMain.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/AbstractMain.java
index 2420111..55dd995 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/AbstractMain.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/AbstractMain.java
@@ -27,6 +27,7 @@
 import com.github.bazelbuild.rules_jvm_external.resolver.Resolver;
 import com.github.bazelbuild.rules_jvm_external.resolver.events.EventListener;
 import com.github.bazelbuild.rules_jvm_external.resolver.events.PhaseEvent;
+import com.github.bazelbuild.rules_jvm_external.resolver.lockfile.DependencyIndex;
 import com.github.bazelbuild.rules_jvm_external.resolver.lockfile.V2LockFile;
 import com.github.bazelbuild.rules_jvm_external.resolver.netrc.Netrc;
 import com.github.bazelbuild.rules_jvm_external.resolver.remote.DownloadResult;
@@ -73,6 +74,7 @@
       infos = fulfillDependencyInfos(resolver, listener, config, resolutionResult);
 
       writeLockFile(listener, config, request, infos, resolutionResult.getConflicts());
+      writeDependencyIndex(config, infos);
 
       System.exit(0);
     } catch (Exception e) {
@@ -201,7 +203,7 @@
         throw new UncheckedIOException(e);
       }
     } else {
-      indexResults = new PerJarIndexResults(new TreeSet<>(), new TreeMap<>());
+      indexResults = new PerJarIndexResults(new TreeSet<>(), new TreeSet<>(), new TreeMap<>());
     }
 
     toReturn.add(
@@ -212,6 +214,7 @@
             result.getSha256(),
             dependencies,
             indexResults.getPackages(),
+            indexResults.getClasses(),
             indexResults.getServiceImplementations()));
 
     if (fetchSources) {
@@ -226,6 +229,7 @@
                 source.getSha256(),
                 ImmutableSet.of(),
                 ImmutableSet.of(),
+                ImmutableSet.of(),
                 ImmutableSortedMap.of()));
       }
     }
@@ -242,6 +246,7 @@
                 javadoc.getSha256(),
                 ImmutableSet.of(),
                 ImmutableSet.of(),
+                ImmutableSet.of(),
                 ImmutableSortedMap.of()));
       }
     }
@@ -261,8 +266,11 @@
 
     listener.close();
 
+    // If a dependency index is being generated, we can omit packages from the lock file
+    // since that information is available in the index file
+    boolean includePackages = config.getDependencyIndexOutput() == null;
     Map<String, Object> rendered =
-        new V2LockFile(request.getRepositories(), infos, conflicts).render();
+        new V2LockFile(request.getRepositories(), infos, conflicts, includePackages).render();
 
     Map<Object, Object> toReturn = new TreeMap<>(rendered);
     // We don't need this, and having it will cause problems
@@ -287,6 +295,24 @@
     }
   }
 
+  private static void writeDependencyIndex(ResolverConfig config, Set<DependencyInfo> infos)
+      throws IOException {
+    Path output = config.getDependencyIndexOutput();
+    if (output == null) {
+      return;
+    }
+
+    Map<String, Object> rendered = new DependencyIndex(infos).render();
+
+    String converted =
+        new GsonBuilder().setPrettyPrinting().serializeNulls().create().toJson(rendered) + "\n";
+
+    try (OutputStream os = Files.newOutputStream(output);
+        BufferedOutputStream bos = new BufferedOutputStream(os)) {
+      bos.write(converted.getBytes(UTF_8));
+    }
+  }
+
   private static int calculateArtifactHash(Map<String, Object> rendered) {
     LinkedHashMap<Object, Object> toHash = new LinkedHashMap<>();
 
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/ResolverConfig.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/ResolverConfig.java
index a4dba95..a296702 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/ResolverConfig.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/ResolverConfig.java
@@ -38,6 +38,7 @@
   private final boolean fetchJavadoc;
   private final Netrc netrc;
   private final Path output;
+  private final Path dependencyIndexOutput;
   private final String inputHash;
   private final int maxThreads;
 
@@ -50,6 +51,7 @@
     boolean fetchJavadoc = false;
     int maxThreads = DEFAULT_MAX_THREADS;
     Path output = null;
+    Path dependencyIndexOutput = null;
     String inputHash = null;
 
     if (System.getenv("RJE_MAX_THREADS") != null) {
@@ -94,6 +96,16 @@
           }
           break;
 
+        case "--dependency-index-output":
+          i++;
+          String workspaceDir = System.getenv("BUILD_WORKSPACE_DIRECTORY");
+          if (workspaceDir == null) {
+            dependencyIndexOutput = Paths.get(args[i]);
+          } else {
+            dependencyIndexOutput = Paths.get(workspaceDir).resolve(args[i]);
+          }
+          break;
+
         case "--sources":
           fetchSources = true;
           break;
@@ -183,6 +195,7 @@
     this.inputHash = inputHash;
     this.maxThreads = maxThreads;
     this.output = output;
+    this.dependencyIndexOutput = dependencyIndexOutput;
   }
 
   public ResolutionRequest getResolutionRequest() {
@@ -212,4 +225,8 @@
   public Path getOutput() {
     return output;
   }
+
+  public Path getDependencyIndexOutput() {
+    return dependencyIndexOutput;
+  }
 }
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/DependencyIndex.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/DependencyIndex.java
new file mode 100644
index 0000000..f6a6a88
--- /dev/null
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/DependencyIndex.java
@@ -0,0 +1,168 @@
+// Copyright 2024 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.bazelbuild.rules_jvm_external.resolver.lockfile;
+
+import com.github.bazelbuild.rules_jvm_external.Coordinates;
+import com.github.bazelbuild.rules_jvm_external.resolver.DependencyInfo;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+/**
+ * Renders a dependency index containing class-level information for each artifact. This is stored
+ * separately from the lock file since class information can be large and is not needed for
+ * resolution.
+ *
+ * <p>To minimize file size:
+ *
+ * <ul>
+ *   <li>Packages that appear in only one artifact are listed in the "packages" section (just
+ *       package names)
+ *   <li>Packages that appear in multiple artifacts (collisions) are listed in the "classes" section
+ *       with full class listings
+ * </ul>
+ *
+ * <p>Example:
+ *
+ * <pre>
+ * {
+ *   "version": 1,
+ *   "packages": {
+ *     "com.google.guava:guava": ["com.google.common.base", "com.google.common.collect"]
+ *   },
+ *   "classes": {
+ *     "com.google.guava:guava": {
+ *       "javax.annotation": ["Nullable"]
+ *     },
+ *     "com.google.code.findbugs:jsr305": {
+ *       "javax.annotation": ["Nonnull", "Nullable"]
+ *     }
+ *   }
+ * }
+ * </pre>
+ */
+public class DependencyIndex {
+
+  private static final int VERSION = 1;
+
+  private final Set<DependencyInfo> infos;
+
+  public DependencyIndex(Set<DependencyInfo> infos) {
+    this.infos = infos;
+  }
+
+  public Map<String, Object> render() {
+    // Step 1: Collect all data and track which packages appear in multiple artifacts
+    // Map: package -> set of artifact keys that contain this package
+    Map<String, Set<String>> packageToArtifacts = new TreeMap<>();
+    // Map: artifact key -> (package -> set of simple class names)
+    Map<String, Map<String, Set<String>>> artifactToPackageToClasses = new TreeMap<>();
+
+    for (DependencyInfo info : infos) {
+      // Skip sources and javadoc artifacts - they don't have classes
+      String classifier = info.getCoordinates().getClassifier();
+      if ("sources".equals(classifier) || "javadoc".equals(classifier)) {
+        continue;
+      }
+
+      Set<String> infoClasses = info.getClasses();
+      if (infoClasses == null || infoClasses.isEmpty()) {
+        continue;
+      }
+
+      String artifactKey = asKey(info.getCoordinates());
+      Map<String, Set<String>> packageToClasses = new TreeMap<>();
+
+      for (String fqcn : infoClasses) {
+        String packageName = extractPackage(fqcn);
+        String simpleClassName = extractSimpleClassName(fqcn);
+
+        packageToClasses.computeIfAbsent(packageName, k -> new TreeSet<>()).add(simpleClassName);
+        packageToArtifacts.computeIfAbsent(packageName, k -> new TreeSet<>()).add(artifactKey);
+      }
+
+      artifactToPackageToClasses.put(artifactKey, packageToClasses);
+    }
+
+    // Step 2: Identify colliding packages (appear in more than one artifact)
+    Set<String> collidingPackages = new TreeSet<>();
+    for (Map.Entry<String, Set<String>> entry : packageToArtifacts.entrySet()) {
+      if (entry.getValue().size() > 1) {
+        collidingPackages.add(entry.getKey());
+      }
+    }
+
+    // Step 3: Build the packages and classes sections
+    // packages: artifact -> [package names] (for unique packages)
+    Map<String, Set<String>> packages = new TreeMap<>();
+    // classes: artifact -> {package -> [class names]} (for colliding packages)
+    Map<String, Map<String, Set<String>>> classes = new TreeMap<>();
+
+    for (Map.Entry<String, Map<String, Set<String>>> entry :
+        artifactToPackageToClasses.entrySet()) {
+      String artifactKey = entry.getKey();
+      Map<String, Set<String>> pkgToClasses = entry.getValue();
+
+      Set<String> uniquePackages = new TreeSet<>();
+      Map<String, Set<String>> collidingClasses = new TreeMap<>();
+
+      for (Map.Entry<String, Set<String>> pkgEntry : pkgToClasses.entrySet()) {
+        String pkg = pkgEntry.getKey();
+        if (collidingPackages.contains(pkg)) {
+          collidingClasses.put(pkg, pkgEntry.getValue());
+        } else {
+          uniquePackages.add(pkg);
+        }
+      }
+
+      if (!uniquePackages.isEmpty()) {
+        packages.put(artifactKey, uniquePackages);
+      }
+      if (!collidingClasses.isEmpty()) {
+        classes.put(artifactKey, collidingClasses);
+      }
+    }
+
+    Map<String, Object> index = new TreeMap<>();
+    index.put(
+        "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY", "THERE_IS_NO_DATA_ONLY_ZUUL");
+    index.put("split_package_classes", classes);
+    index.put("packages", packages);
+    index.put("version", VERSION);
+
+    return index;
+  }
+
+  private static String asKey(Coordinates coords) {
+    return coords.asKey().toString();
+  }
+
+  private static String extractPackage(String fqcn) {
+    int lastDot = fqcn.lastIndexOf('.');
+    if (lastDot == -1) {
+      return "";
+    }
+    return fqcn.substring(0, lastDot);
+  }
+
+  private static String extractSimpleClassName(String fqcn) {
+    int lastDot = fqcn.lastIndexOf('.');
+    if (lastDot == -1) {
+      return fqcn;
+    }
+    return fqcn.substring(lastDot + 1);
+  }
+}
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/V2LockFile.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/V2LockFile.java
index a85d9b7..018aa2f 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/V2LockFile.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/V2LockFile.java
@@ -43,12 +43,17 @@
   private final Collection<URI> allRepos;
   private final Set<DependencyInfo> infos;
   private final Set<Conflict> conflicts;
+  private final boolean renderPackages;
 
   public V2LockFile(
-      Collection<URI> repositories, Set<DependencyInfo> infos, Set<Conflict> conflicts) {
+      Collection<URI> repositories,
+      Set<DependencyInfo> infos,
+      Set<Conflict> conflicts,
+      boolean renderPackages) {
     this.allRepos = repositories;
     this.infos = infos;
     this.conflicts = conflicts;
+    this.renderPackages = renderPackages;
   }
 
   public Collection<URI> getRepositories() {
@@ -156,6 +161,7 @@
               Optional.of(entry.getValue()),
               dependencies.getOrDefault(coords, Set.of()),
               Set.of(),
+              Set.of(),
               new TreeMap<>()));
     }
 
@@ -171,10 +177,9 @@
       conflicts.add(new Conflict(resolved, requested));
     }
 
-    return new V2LockFile(repos, infos, conflicts);
+    return new V2LockFile(repos, infos, conflicts, true);
   }
 
-  /** "Render" the resolution result to a `Map` suitable for printing as JSON. */
   public Map<String, Object> render() {
     Set<URI> repositories = new LinkedHashSet<>(allRepos);
 
@@ -242,7 +247,9 @@
                   .map(Coordinates::asKey)
                   .map(Object::toString)
                   .collect(Collectors.toCollection(TreeSet::new)));
-          packages.put(key, info.getPackages());
+          if (renderPackages) {
+            packages.put(key, info.getPackages());
+          }
           services.put(key, info.getServices());
 
           if (info.getPath().isPresent()) {
@@ -254,7 +261,9 @@
     Map<String, Object> lock = new LinkedHashMap<>();
     lock.put("artifacts", ensureArtifactsAllHaveAtLeastOneShaSum(artifacts));
     lock.put("dependencies", removeEmptyItems(deps));
-    lock.put("packages", removeEmptyItems(packages));
+    if (renderPackages) {
+      lock.put("packages", removeEmptyItems(packages));
+    }
     lock.put("services", removeEmptyItemsMap(services));
     if (isUsingM2Local) {
       lock.put("m2local", true);
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/BUILD b/tests/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/BUILD
index a53c0cc..0c26ea3 100644
--- a/tests/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/BUILD
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/BUILD
@@ -2,6 +2,23 @@
 load("//:defs.bzl", "artifact")
 
 java_test(
+    name = "DependencyIndexTest",
+    srcs = ["DependencyIndexTest.java"],
+    test_class = "com.github.bazelbuild.rules_jvm_external.resolver.lockfile.DependencyIndexTest",
+    deps = [
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external",
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver",
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/lockfile",
+        artifact(
+            "junit:junit",
+            repository_name = "regression_testing_coursier",
+        ),
+        artifact("org.hamcrest:hamcrest"),
+        artifact("org.hamcrest:hamcrest_core"),
+    ],
+)
+
+java_test(
     name = "V2LockFileTest",
     srcs = ["V2LockFileTest.java"],
     test_class = "com.github.bazelbuild.rules_jvm_external.resolver.lockfile.V2LockFileTest",
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/DependencyIndexTest.java b/tests/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/DependencyIndexTest.java
new file mode 100644
index 0000000..ec84b9d
--- /dev/null
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/DependencyIndexTest.java
@@ -0,0 +1,311 @@
+// Copyright 2024 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.bazelbuild.rules_jvm_external.resolver.lockfile;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.github.bazelbuild.rules_jvm_external.Coordinates;
+import com.github.bazelbuild.rules_jvm_external.resolver.DependencyInfo;
+import java.net.URI;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import org.junit.Test;
+
+public class DependencyIndexTest {
+
+  private final URI defaultRepo = URI.create("http://localhost/m2/repository/");
+  private final Set<URI> repos = Set.of(defaultRepo);
+
+  @Test
+  public void shouldIncludeVersionNumber() {
+    DependencyIndex index = new DependencyIndex(Set.of());
+    Map<String, Object> rendered = index.render();
+
+    assertEquals(1, rendered.get("version"));
+  }
+
+  @Test
+  public void shouldRenderEmptySectionsForEmptyInfos() {
+    DependencyIndex index = new DependencyIndex(Set.of());
+    Map<String, Object> rendered = index.render();
+
+    @SuppressWarnings("unchecked")
+    Map<String, Set<String>> packages = (Map<String, Set<String>>) rendered.get("packages");
+    assertTrue(packages.isEmpty());
+
+    @SuppressWarnings("unchecked")
+    Map<String, Map<String, Set<String>>> classes =
+        (Map<String, Map<String, Set<String>>>) rendered.get("split_package_classes");
+    assertTrue(classes.isEmpty());
+  }
+
+  @Test
+  public void shouldPutUniquePackagesInPackagesSection() {
+    Set<String> artifactClasses = new TreeSet<>();
+    artifactClasses.add("com.example.Foo");
+    artifactClasses.add("com.example.Bar");
+    artifactClasses.add("com.example.sub.Baz");
+
+    DependencyInfo info =
+        new DependencyInfo(
+            new Coordinates("com.example:item:1.0.0"),
+            repos,
+            Optional.empty(),
+            Optional.of("abc123"),
+            Set.of(),
+            Set.of("com.example", "com.example.sub"),
+            artifactClasses,
+            new TreeMap<>());
+
+    DependencyIndex index = new DependencyIndex(Set.of(info));
+    Map<String, Object> rendered = index.render();
+
+    // Unique packages go to "packages" section
+    @SuppressWarnings("unchecked")
+    Map<String, Set<String>> packages = (Map<String, Set<String>>) rendered.get("packages");
+    assertEquals(1, packages.size());
+    assertEquals(Set.of("com.example", "com.example.sub"), packages.get("com.example:item"));
+
+    // No collisions, so "split_package_classes" section should be empty for this artifact
+    @SuppressWarnings("unchecked")
+    Map<String, Map<String, Set<String>>> classes =
+        (Map<String, Map<String, Set<String>>>) rendered.get("split_package_classes");
+    assertTrue(classes.isEmpty());
+  }
+
+  @Test
+  public void shouldPutCollidingPackagesInClassesSection() {
+    // Two artifacts share the same package "javax.annotation"
+    Set<String> guavaClasses = new TreeSet<>();
+    guavaClasses.add("javax.annotation.Nullable");
+    guavaClasses.add("com.google.common.base.Optional");
+
+    Set<String> jsr305Classes = new TreeSet<>();
+    jsr305Classes.add("javax.annotation.Nonnull");
+    jsr305Classes.add("javax.annotation.Nullable");
+
+    DependencyInfo guava =
+        new DependencyInfo(
+            new Coordinates("com.google.guava:guava:33.0.0"),
+            repos,
+            Optional.empty(),
+            Optional.of("abc123"),
+            Set.of(),
+            Set.of("javax.annotation", "com.google.common.base"),
+            guavaClasses,
+            new TreeMap<>());
+
+    DependencyInfo jsr305 =
+        new DependencyInfo(
+            new Coordinates("com.google.code.findbugs:jsr305:3.0.2"),
+            repos,
+            Optional.empty(),
+            Optional.of("def456"),
+            Set.of(),
+            Set.of("javax.annotation"),
+            jsr305Classes,
+            new TreeMap<>());
+
+    DependencyIndex index = new DependencyIndex(Set.of(guava, jsr305));
+    Map<String, Object> rendered = index.render();
+
+    // Unique packages go to "packages" section
+    @SuppressWarnings("unchecked")
+    Map<String, Set<String>> packages = (Map<String, Set<String>>) rendered.get("packages");
+    assertEquals(1, packages.size());
+    assertEquals(Set.of("com.google.common.base"), packages.get("com.google.guava:guava"));
+    assertNull(packages.get("com.google.code.findbugs:jsr305")); // jsr305 has no unique packages
+
+    // Colliding package goes to "split_package_classes" section with full class listings
+    @SuppressWarnings("unchecked")
+    Map<String, Map<String, Set<String>>> classes =
+        (Map<String, Map<String, Set<String>>>) rendered.get("split_package_classes");
+    assertEquals(2, classes.size());
+
+    Map<String, Set<String>> guavaCollisions = classes.get("com.google.guava:guava");
+    assertEquals(1, guavaCollisions.size());
+    assertEquals(Set.of("Nullable"), guavaCollisions.get("javax.annotation"));
+
+    Map<String, Set<String>> jsr305Collisions = classes.get("com.google.code.findbugs:jsr305");
+    assertEquals(1, jsr305Collisions.size());
+    assertEquals(Set.of("Nonnull", "Nullable"), jsr305Collisions.get("javax.annotation"));
+  }
+
+  @Test
+  public void shouldHandleArtifactWithBothUniqueAndCollidingPackages() {
+    Set<String> artifact1Classes = new TreeSet<>();
+    artifact1Classes.add("com.shared.Foo");
+    artifact1Classes.add("com.unique1.Bar");
+
+    Set<String> artifact2Classes = new TreeSet<>();
+    artifact2Classes.add("com.shared.Baz");
+    artifact2Classes.add("com.unique2.Qux");
+
+    DependencyInfo artifact1 =
+        new DependencyInfo(
+            new Coordinates("com.example:artifact1:1.0.0"),
+            repos,
+            Optional.empty(),
+            Optional.of("abc123"),
+            Set.of(),
+            Set.of("com.shared", "com.unique1"),
+            artifact1Classes,
+            new TreeMap<>());
+
+    DependencyInfo artifact2 =
+        new DependencyInfo(
+            new Coordinates("com.example:artifact2:1.0.0"),
+            repos,
+            Optional.empty(),
+            Optional.of("def456"),
+            Set.of(),
+            Set.of("com.shared", "com.unique2"),
+            artifact2Classes,
+            new TreeMap<>());
+
+    DependencyIndex index = new DependencyIndex(Set.of(artifact1, artifact2));
+    Map<String, Object> rendered = index.render();
+
+    // Unique packages in "packages" section
+    @SuppressWarnings("unchecked")
+    Map<String, Set<String>> packages = (Map<String, Set<String>>) rendered.get("packages");
+    assertEquals(2, packages.size());
+    assertEquals(Set.of("com.unique1"), packages.get("com.example:artifact1"));
+    assertEquals(Set.of("com.unique2"), packages.get("com.example:artifact2"));
+
+    // Colliding package in "split_package_classes" section
+    @SuppressWarnings("unchecked")
+    Map<String, Map<String, Set<String>>> classes =
+        (Map<String, Map<String, Set<String>>>) rendered.get("split_package_classes");
+    assertEquals(2, classes.size());
+
+    assertEquals(Set.of("Foo"), classes.get("com.example:artifact1").get("com.shared"));
+    assertEquals(Set.of("Baz"), classes.get("com.example:artifact2").get("com.shared"));
+  }
+
+  @Test
+  public void shouldHandleDefaultPackage() {
+    Set<String> artifactClasses = new TreeSet<>();
+    artifactClasses.add("Foo");
+
+    DependencyInfo info =
+        new DependencyInfo(
+            new Coordinates("com.example:item:1.0.0"),
+            repos,
+            Optional.empty(),
+            Optional.of("abc123"),
+            Set.of(),
+            Set.of(""),
+            artifactClasses,
+            new TreeMap<>());
+
+    DependencyIndex index = new DependencyIndex(Set.of(info));
+    Map<String, Object> rendered = index.render();
+
+    // Default package (empty string) should be in packages section since it's unique
+    @SuppressWarnings("unchecked")
+    Map<String, Set<String>> packages = (Map<String, Set<String>>) rendered.get("packages");
+    assertEquals(Set.of(""), packages.get("com.example:item"));
+  }
+
+  @Test
+  public void shouldSkipSourcesArtifacts() {
+    Set<String> artifactClasses = new TreeSet<>();
+    artifactClasses.add("com.example.Foo");
+
+    DependencyInfo sources =
+        new DependencyInfo(
+            new Coordinates("com.example:item:jar:sources:1.0.0"),
+            repos,
+            Optional.empty(),
+            Optional.of("abc123"),
+            Set.of(),
+            Set.of(),
+            artifactClasses,
+            new TreeMap<>());
+
+    DependencyIndex index = new DependencyIndex(Set.of(sources));
+    Map<String, Object> rendered = index.render();
+
+    @SuppressWarnings("unchecked")
+    Map<String, Set<String>> packages = (Map<String, Set<String>>) rendered.get("packages");
+    assertTrue(packages.isEmpty());
+
+    @SuppressWarnings("unchecked")
+    Map<String, Map<String, Set<String>>> classes =
+        (Map<String, Map<String, Set<String>>>) rendered.get("split_package_classes");
+    assertTrue(classes.isEmpty());
+  }
+
+  @Test
+  public void shouldSkipJavadocArtifacts() {
+    Set<String> artifactClasses = new TreeSet<>();
+    artifactClasses.add("com.example.Foo");
+
+    DependencyInfo javadoc =
+        new DependencyInfo(
+            new Coordinates("com.example:item:jar:javadoc:1.0.0"),
+            repos,
+            Optional.empty(),
+            Optional.of("abc123"),
+            Set.of(),
+            Set.of(),
+            artifactClasses,
+            new TreeMap<>());
+
+    DependencyIndex index = new DependencyIndex(Set.of(javadoc));
+    Map<String, Object> rendered = index.render();
+
+    @SuppressWarnings("unchecked")
+    Map<String, Set<String>> packages = (Map<String, Set<String>>) rendered.get("packages");
+    assertTrue(packages.isEmpty());
+
+    @SuppressWarnings("unchecked")
+    Map<String, Map<String, Set<String>>> classes =
+        (Map<String, Map<String, Set<String>>>) rendered.get("split_package_classes");
+    assertTrue(classes.isEmpty());
+  }
+
+  @Test
+  public void shouldSkipArtifactsWithNoClasses() {
+    DependencyInfo info =
+        new DependencyInfo(
+            new Coordinates("com.example:item:1.0.0"),
+            repos,
+            Optional.empty(),
+            Optional.of("abc123"),
+            Set.of(),
+            Set.of("com.example"),
+            Set.of(),
+            new TreeMap<>());
+
+    DependencyIndex index = new DependencyIndex(Set.of(info));
+    Map<String, Object> rendered = index.render();
+
+    @SuppressWarnings("unchecked")
+    Map<String, Set<String>> packages = (Map<String, Set<String>>) rendered.get("packages");
+    assertTrue(packages.isEmpty());
+
+    @SuppressWarnings("unchecked")
+    Map<String, Map<String, Set<String>>> classes =
+        (Map<String, Map<String, Set<String>>>) rendered.get("split_package_classes");
+    assertTrue(classes.isEmpty());
+  }
+}
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/V2LockFileTest.java b/tests/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/V2LockFileTest.java
index e1c1038..b02734a 100644
--- a/tests/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/V2LockFileTest.java
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/V2LockFileTest.java
@@ -15,6 +15,9 @@
 package com.github.bazelbuild.rules_jvm_external.resolver.lockfile;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
 
 import com.github.bazelbuild.rules_jvm_external.Coordinates;
 import com.github.bazelbuild.rules_jvm_external.resolver.Conflict;
@@ -43,9 +46,11 @@
             Optional.empty(),
             Set.of(),
             Set.of(),
+            Set.of(),
             new TreeMap<>());
 
-    Map<String, Object> rendered = new V2LockFile(repos, Set.of(aggregator), Set.of()).render();
+    Map<String, Object> rendered =
+        new V2LockFile(repos, Set.of(aggregator), Set.of(), true).render();
 
     Map<?, ?> artifacts = (Map<?, ?>) rendered.get("artifacts");
     Map<?, ?> data = (Map<?, ?>) artifacts.get("com.example:aggregator");
@@ -58,7 +63,7 @@
 
   @Test
   public void shouldRoundTripASimpleSetOfDependencies() {
-    V2LockFile roundTripped = roundTrip(new V2LockFile(repos, Set.of(), Set.of()));
+    V2LockFile roundTripped = roundTrip(new V2LockFile(repos, Set.of(), Set.of(), true));
 
     assertEquals(repos, roundTripped.getRepositories());
     assertEquals(Set.of(), roundTripped.getDependencyInfos());
@@ -67,7 +72,7 @@
 
   @Test
   public void shouldRoundTripM2Local() {
-    V2LockFile lockFile = new V2LockFile(repos, Set.of(), Set.of());
+    V2LockFile lockFile = new V2LockFile(repos, Set.of(), Set.of(), true);
     Map<String, Object> rendered = lockFile.render();
     rendered.put("m2local", true);
 
@@ -88,9 +93,10 @@
             Optional.of("c2c97a708be197aae5fee64dcc8b5e8a09c76c79a44c0e8e5b48b235084ec395"),
             Set.of(),
             Set.of(),
+            Set.of(),
             new TreeMap<>());
 
-    V2LockFile lockFile = roundTrip(new V2LockFile(repos, Set.of(info), Set.of()));
+    V2LockFile lockFile = roundTrip(new V2LockFile(repos, Set.of(info), Set.of(), true));
 
     assertEquals(Set.of(info), lockFile.getDependencyInfos());
   }
@@ -107,6 +113,7 @@
             Optional.of("cafebad08be197aae5fee64dcc8b5e8a09c76c79a44c0e8e5b48b235084ec395"),
             Set.of(),
             Set.of(),
+            Set.of(),
             new TreeMap<>());
 
     DependencyInfo info =
@@ -117,9 +124,10 @@
             Optional.of("c2c97a708be197aae5fee64dcc8b5e8a09c76c79a44c0e8e5b48b235084ec395"),
             Set.of(depCoords),
             Set.of(),
+            Set.of(),
             new TreeMap<>());
 
-    V2LockFile lockFile = roundTrip(new V2LockFile(repos, Set.of(info, dep), Set.of()));
+    V2LockFile lockFile = roundTrip(new V2LockFile(repos, Set.of(info, dep), Set.of(), true));
 
     assertEquals(Set.of(info, dep), lockFile.getDependencyInfos());
   }
@@ -133,11 +141,77 @@
             new Conflict(
                 new Coordinates("com.foo:bar:1.2.3"), new Coordinates("com.foo:bar:1.2.1")));
 
-    V2LockFile lockFile = roundTrip(new V2LockFile(repos, Set.of(), conflicts));
+    V2LockFile lockFile = roundTrip(new V2LockFile(repos, Set.of(), conflicts, true));
 
     assertEquals(conflicts, lockFile.getConflicts());
   }
 
+  @Test
+  public void shouldIncludePackagesWhenIncludePackagesIsTrue() {
+    DependencyInfo info =
+        new DependencyInfo(
+            new Coordinates("com.example:item:1.0.0"),
+            repos,
+            Optional.empty(),
+            Optional.of("abc123"),
+            Set.of(),
+            Set.of("com.example", "com.example.sub"),
+            Set.of(),
+            new TreeMap<>());
+
+    Map<String, Object> rendered = new V2LockFile(repos, Set.of(info), Set.of(), true).render();
+
+    assertNotNull(rendered.get("packages"));
+    @SuppressWarnings("unchecked")
+    Map<String, Set<String>> packages = (Map<String, Set<String>>) rendered.get("packages");
+    assertFalse(packages.isEmpty());
+    assertEquals(Set.of("com.example", "com.example.sub"), packages.get("com.example:item"));
+  }
+
+  @Test
+  public void shouldExcludePackagesWhenIncludePackagesIsFalse() {
+    DependencyInfo info =
+        new DependencyInfo(
+            new Coordinates("com.example:item:1.0.0"),
+            repos,
+            Optional.empty(),
+            Optional.of("abc123"),
+            Set.of(),
+            Set.of("com.example", "com.example.sub"),
+            Set.of(),
+            new TreeMap<>());
+
+    Map<String, Object> rendered = new V2LockFile(repos, Set.of(info), Set.of(), false).render();
+
+    assertNull(rendered.get("packages"));
+  }
+
+  @Test
+  public void shouldStillIncludeOtherFieldsWhenPackagesExcluded() {
+    DependencyInfo info =
+        new DependencyInfo(
+            new Coordinates("com.example:item:1.0.0"),
+            repos,
+            Optional.empty(),
+            Optional.of("abc123"),
+            Set.of(),
+            Set.of("com.example"),
+            Set.of(),
+            new TreeMap<>());
+
+    Map<String, Object> rendered = new V2LockFile(repos, Set.of(info), Set.of(), false).render();
+
+    // Packages should be excluded
+    assertNull(rendered.get("packages"));
+
+    // But other fields should still be present
+    assertNotNull(rendered.get("artifacts"));
+    assertNotNull(rendered.get("dependencies"));
+    assertNotNull(rendered.get("services"));
+    assertNotNull(rendered.get("repositories"));
+    assertEquals("2", rendered.get("version"));
+  }
+
   private V2LockFile roundTrip(V2LockFile lockFile) {
     Map<String, Object> rendered = lockFile.render();
     String converted =
diff --git a/tests/custom_maven_install/regression_testing_gradle_index.json b/tests/custom_maven_install/regression_testing_gradle_index.json
new file mode 100644
index 0000000..6c4961a
--- /dev/null
+++ b/tests/custom_maven_install/regression_testing_gradle_index.json
@@ -0,0 +1,164 @@
+{
+  "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
+  "packages": {
+    "androidx.annotation:annotation-jvm": [
+      "androidx.annotation"
+    ],
+    "androidx.arch.core:core-common": [
+      "androidx.arch.core.internal",
+      "androidx.arch.core.util"
+    ],
+    "androidx.concurrent:concurrent-futures": [
+      "androidx.concurrent.futures"
+    ],
+    "androidx.lifecycle:lifecycle-common": [
+      "androidx.lifecycle"
+    ],
+    "ch.qos.reload4j:reload4j": [
+      "org.apache.log4j",
+      "org.apache.log4j.chainsaw",
+      "org.apache.log4j.config",
+      "org.apache.log4j.helpers",
+      "org.apache.log4j.jdbc",
+      "org.apache.log4j.net",
+      "org.apache.log4j.or",
+      "org.apache.log4j.or.jms",
+      "org.apache.log4j.or.sax",
+      "org.apache.log4j.pattern",
+      "org.apache.log4j.rewrite",
+      "org.apache.log4j.spi",
+      "org.apache.log4j.varia",
+      "org.apache.log4j.xml"
+    ],
+    "com.almworks.sqlite4java:sqlite4java": [
+      "com.almworks.sqlite4java",
+      "javolution.util.stripped"
+    ],
+    "com.google.guava:listenablefuture": [
+      "com.google.common.util.concurrent"
+    ],
+    "com.squareup.okhttp3:okhttp": [
+      "okhttp3",
+      "okhttp3.internal",
+      "okhttp3.internal.authenticator",
+      "okhttp3.internal.cache",
+      "okhttp3.internal.cache2",
+      "okhttp3.internal.concurrent",
+      "okhttp3.internal.connection",
+      "okhttp3.internal.http",
+      "okhttp3.internal.http1",
+      "okhttp3.internal.http2",
+      "okhttp3.internal.io",
+      "okhttp3.internal.platform",
+      "okhttp3.internal.platform.android",
+      "okhttp3.internal.proxy",
+      "okhttp3.internal.publicsuffix",
+      "okhttp3.internal.tls",
+      "okhttp3.internal.ws"
+    ],
+    "com.squareup.okio:okio-jvm": [
+      "okio",
+      "okio.internal"
+    ],
+    "org.jetbrains.kotlin:kotlin-stdlib": [
+      "kotlin",
+      "kotlin.annotation",
+      "kotlin.collections",
+      "kotlin.collections.builders",
+      "kotlin.collections.jdk8",
+      "kotlin.collections.unsigned",
+      "kotlin.comparisons",
+      "kotlin.concurrent",
+      "kotlin.contracts",
+      "kotlin.coroutines",
+      "kotlin.coroutines.cancellation",
+      "kotlin.coroutines.intrinsics",
+      "kotlin.coroutines.jvm.internal",
+      "kotlin.enums",
+      "kotlin.experimental",
+      "kotlin.internal",
+      "kotlin.internal.jdk7",
+      "kotlin.internal.jdk8",
+      "kotlin.io",
+      "kotlin.io.encoding",
+      "kotlin.io.path",
+      "kotlin.jdk7",
+      "kotlin.js",
+      "kotlin.jvm",
+      "kotlin.jvm.functions",
+      "kotlin.jvm.internal",
+      "kotlin.jvm.internal.markers",
+      "kotlin.jvm.internal.unsafe",
+      "kotlin.jvm.jdk8",
+      "kotlin.jvm.optionals",
+      "kotlin.math",
+      "kotlin.properties",
+      "kotlin.random",
+      "kotlin.random.jdk8",
+      "kotlin.ranges",
+      "kotlin.reflect",
+      "kotlin.sequences",
+      "kotlin.streams.jdk8",
+      "kotlin.system",
+      "kotlin.text",
+      "kotlin.text.jdk8",
+      "kotlin.time",
+      "kotlin.time.jdk8"
+    ],
+    "org.jetbrains.kotlinx:kotlinx-coroutines-android": [
+      "kotlinx.coroutines.android"
+    ],
+    "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm": [
+      "kotlinx.coroutines",
+      "kotlinx.coroutines.channels",
+      "kotlinx.coroutines.debug",
+      "kotlinx.coroutines.debug.internal",
+      "kotlinx.coroutines.flow",
+      "kotlinx.coroutines.flow.internal",
+      "kotlinx.coroutines.internal",
+      "kotlinx.coroutines.intrinsics",
+      "kotlinx.coroutines.scheduling",
+      "kotlinx.coroutines.selects",
+      "kotlinx.coroutines.sync"
+    ],
+    "org.jetbrains:annotations": [
+      "org.intellij.lang.annotations",
+      "org.jetbrains.annotations"
+    ],
+    "org.slf4j:slf4j-api": [
+      "org.slf4j",
+      "org.slf4j.event",
+      "org.slf4j.helpers",
+      "org.slf4j.spi"
+    ],
+    "org.slf4j:slf4j-reload4j": [
+      "org.slf4j.reload4j"
+    ]
+  },
+  "split_package_classes": {
+    "androidx.collection:collection": {
+      "androidx.collection": [
+        "ArrayMap",
+        "ArraySet",
+        "CircularArray",
+        "CircularIntArray",
+        "ContainerHelpers",
+        "LongSparseArray",
+        "LruCache",
+        "MapCollections",
+        "SimpleArrayMap",
+        "SparseArrayCompat"
+      ]
+    },
+    "androidx.collection:collection-ktx": {
+      "androidx.collection": [
+        "ArrayMapKt",
+        "ArraySetKt",
+        "LongSparseArrayKt",
+        "LruCacheKt",
+        "SparseArrayKt"
+      ]
+    }
+  },
+  "version": 1
+}
diff --git a/tests/custom_maven_install/regression_testing_gradle_install.json b/tests/custom_maven_install/regression_testing_gradle_install.json
index 29e297f..0476c34 100644
--- a/tests/custom_maven_install/regression_testing_gradle_install.json
+++ b/tests/custom_maven_install/regression_testing_gradle_install.json
@@ -952,147 +952,6 @@
       "org.slf4j:slf4j-api"
     ]
   },
-  "packages": {
-    "androidx.annotation:annotation-jvm": [
-      "androidx.annotation"
-    ],
-    "androidx.arch.core:core-common": [
-      "androidx.arch.core.internal",
-      "androidx.arch.core.util"
-    ],
-    "androidx.collection:collection": [
-      "androidx.collection"
-    ],
-    "androidx.collection:collection-ktx": [
-      "androidx.collection"
-    ],
-    "androidx.concurrent:concurrent-futures": [
-      "androidx.concurrent.futures"
-    ],
-    "androidx.lifecycle:lifecycle-common": [
-      "androidx.lifecycle"
-    ],
-    "ch.qos.reload4j:reload4j": [
-      "org.apache.log4j",
-      "org.apache.log4j.chainsaw",
-      "org.apache.log4j.config",
-      "org.apache.log4j.helpers",
-      "org.apache.log4j.jdbc",
-      "org.apache.log4j.net",
-      "org.apache.log4j.or",
-      "org.apache.log4j.or.jms",
-      "org.apache.log4j.or.sax",
-      "org.apache.log4j.pattern",
-      "org.apache.log4j.rewrite",
-      "org.apache.log4j.spi",
-      "org.apache.log4j.varia",
-      "org.apache.log4j.xml"
-    ],
-    "com.almworks.sqlite4java:sqlite4java": [
-      "com.almworks.sqlite4java",
-      "javolution.util.stripped"
-    ],
-    "com.google.guava:listenablefuture": [
-      "com.google.common.util.concurrent"
-    ],
-    "com.squareup.okhttp3:okhttp": [
-      "okhttp3",
-      "okhttp3.internal",
-      "okhttp3.internal.authenticator",
-      "okhttp3.internal.cache",
-      "okhttp3.internal.cache2",
-      "okhttp3.internal.concurrent",
-      "okhttp3.internal.connection",
-      "okhttp3.internal.http",
-      "okhttp3.internal.http1",
-      "okhttp3.internal.http2",
-      "okhttp3.internal.io",
-      "okhttp3.internal.platform",
-      "okhttp3.internal.platform.android",
-      "okhttp3.internal.proxy",
-      "okhttp3.internal.publicsuffix",
-      "okhttp3.internal.tls",
-      "okhttp3.internal.ws"
-    ],
-    "com.squareup.okio:okio-jvm": [
-      "okio",
-      "okio.internal"
-    ],
-    "org.jetbrains.kotlin:kotlin-stdlib": [
-      "kotlin",
-      "kotlin.annotation",
-      "kotlin.collections",
-      "kotlin.collections.builders",
-      "kotlin.collections.jdk8",
-      "kotlin.collections.unsigned",
-      "kotlin.comparisons",
-      "kotlin.concurrent",
-      "kotlin.contracts",
-      "kotlin.coroutines",
-      "kotlin.coroutines.cancellation",
-      "kotlin.coroutines.intrinsics",
-      "kotlin.coroutines.jvm.internal",
-      "kotlin.enums",
-      "kotlin.experimental",
-      "kotlin.internal",
-      "kotlin.internal.jdk7",
-      "kotlin.internal.jdk8",
-      "kotlin.io",
-      "kotlin.io.encoding",
-      "kotlin.io.path",
-      "kotlin.jdk7",
-      "kotlin.js",
-      "kotlin.jvm",
-      "kotlin.jvm.functions",
-      "kotlin.jvm.internal",
-      "kotlin.jvm.internal.markers",
-      "kotlin.jvm.internal.unsafe",
-      "kotlin.jvm.jdk8",
-      "kotlin.jvm.optionals",
-      "kotlin.math",
-      "kotlin.properties",
-      "kotlin.random",
-      "kotlin.random.jdk8",
-      "kotlin.ranges",
-      "kotlin.reflect",
-      "kotlin.sequences",
-      "kotlin.streams.jdk8",
-      "kotlin.system",
-      "kotlin.text",
-      "kotlin.text.jdk8",
-      "kotlin.time",
-      "kotlin.time.jdk8"
-    ],
-    "org.jetbrains.kotlinx:kotlinx-coroutines-android": [
-      "kotlinx.coroutines.android"
-    ],
-    "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm": [
-      "kotlinx.coroutines",
-      "kotlinx.coroutines.channels",
-      "kotlinx.coroutines.debug",
-      "kotlinx.coroutines.debug.internal",
-      "kotlinx.coroutines.flow",
-      "kotlinx.coroutines.flow.internal",
-      "kotlinx.coroutines.internal",
-      "kotlinx.coroutines.intrinsics",
-      "kotlinx.coroutines.scheduling",
-      "kotlinx.coroutines.selects",
-      "kotlinx.coroutines.sync"
-    ],
-    "org.jetbrains:annotations": [
-      "org.intellij.lang.annotations",
-      "org.jetbrains.annotations"
-    ],
-    "org.slf4j:slf4j-api": [
-      "org.slf4j",
-      "org.slf4j.event",
-      "org.slf4j.helpers",
-      "org.slf4j.spi"
-    ],
-    "org.slf4j:slf4j-reload4j": [
-      "org.slf4j.reload4j"
-    ]
-  },
   "repositories": {
     "https://repo1.maven.org/maven2/": [
       "ch.qos.reload4j:reload4j",