fix(gazelle): fix module map for old-style namespaces (#4135)

The performance tweak in #3415 which parallelized gazelle module map
generation calls simplify() on wheels individually, which always results
in entries which collapse to the highest level modules which contain
__init__.py files. But when two wheels do old-style namespace packages
by both including that file under their top-level module, they simplify
down to the top-level module, clobber each other when merged, and wind
up with one wheel getting mapped for the entire namespace. This causes
bug #3528.

We fix this by deferring our call to simplify() until after we've merged
all the wheels' module info.

Fixes #3528
diff --git a/gazelle/modules_mapping/generator.py b/gazelle/modules_mapping/generator.py
index 611910c..e195473 100644
--- a/gazelle/modules_mapping/generator.py
+++ b/gazelle/modules_mapping/generator.py
@@ -54,23 +54,6 @@
                 else:
                     self.module_for_path(path, whl)
 
-    def simplify(self):
-        simplified = {}
-        for module, wheel_name in sorted(self.mapping.items(), key=lambda x: x[0]):
-            mod = module
-            while True:
-                if mod in simplified:
-                    if simplified[mod] != wheel_name:
-                        break
-                    wheel_name = ""
-                    break
-                if mod.count(".") == 0:
-                    break
-                mod = mod.rsplit(".", 1)[0]
-            if wheel_name:
-                simplified[module] = wheel_name
-        self.mapping = simplified
-
     def module_for_path(self, path, whl):
         ext = pathlib.Path(path).suffix
         if ext == ".py" or ext == ".so":
@@ -118,7 +101,6 @@
         except AssertionError as error:
             print(error, file=self.stderr)
             return 1
-        self.simplify()
         mapping_json = json.dumps(self.mapping)
         with open(self.output_file, "w") as f:
             f.write(mapping_json)
diff --git a/gazelle/modules_mapping/merger.py b/gazelle/modules_mapping/merger.py
index deb0cb2..a536dc2 100644
--- a/gazelle/modules_mapping/merger.py
+++ b/gazelle/modules_mapping/merger.py
@@ -6,6 +6,36 @@
 from pathlib import Path
 
 
+def simplify(mapping: dict) -> dict:
+    """Collapse entries for submodules into entries for their parents
+    where possible.  For example, "a.b" and "a.c" become "a".
+
+    This must run over the fully merged mapping; a submodule is redundant only
+    when some ancestor resolves to the same wheel, and we can determine that
+    only once every wheel has contributed.
+
+    Args:
+        mapping: The fully merged module-to-wheel mapping to simplify.
+    Returns:
+        A new, simplified module-to-wheel mapping.
+    """
+    simplified = {}
+    for module, wheel_name in sorted(mapping.items(), key=lambda x: x[0]):
+        mod = module
+        while True:
+            if mod in simplified:
+                if simplified[mod] != wheel_name:
+                    break
+                wheel_name = ""
+                break
+            if mod.count(".") == 0:
+                break
+            mod = mod.rsplit(".", 1)[0]
+        if wheel_name:
+            simplified[module] = wheel_name
+    return simplified
+
+
 def merge_modules_mappings(input_files: list[Path], output_file: Path) -> None:
     """Merge multiple modules_mapping.json files into one.
 
@@ -20,7 +50,7 @@
         # if there are conflicts
         merged_mapping.update(mapping)
 
-    output_file.write_text(json.dumps(merged_mapping))
+    output_file.write_text(json.dumps(simplify(merged_mapping)))
 
 
 if __name__ == "__main__":
diff --git a/gazelle/modules_mapping/test_merger.py b/gazelle/modules_mapping/test_merger.py
index 87c35f4..845e1f9 100644
--- a/gazelle/modules_mapping/test_merger.py
+++ b/gazelle/modules_mapping/test_merger.py
@@ -48,14 +48,54 @@
         self.assertEqual(
             {
                 "_pytest": "pytest",
-                "_pytest.__init__": "pytest",
-                "_pytest._argcomplete": "pytest",
-                "_pytest.config.argparsing": "pytest",
                 "django_types": "django_types",
             },
             json.loads(output_path.read_text()),
         )
 
+    def test_merger_keeps_distinct_namespace_package_submodules(self):
+        # Regression test for https://github.com/bazel-contrib/rules_python/issues/3528.
+        #
+        # Two wheels ("bosdyn_client" and "bosdyn_orbit") both contribute to the
+        # "bosdyn" namespace package, each shipping their own "bosdyn" entry
+        # (from the namespace package's __init__.py) alongside their own
+        # wheel-specific submodule. Since https://github.com/bazel-contrib/rules_python/pull/3415,
+        # each wheel's mapping is generated (and, before this fix, simplified)
+        # independently, so the merge must not let one wheel's "bosdyn" entry
+        # clobber the other's more specific submodule entries.
+        output_path = self.tmppath / "output.json"
+        merge_modules_mappings(
+            [
+                self.make_input(
+                    {
+                        "bosdyn": "bosdyn_client",
+                        "bosdyn.client": "bosdyn_client",
+                        "bosdyn.client.control": "bosdyn_client",
+                    }
+                ),
+                self.make_input(
+                    {
+                        "bosdyn": "bosdyn_orbit",
+                        "bosdyn.orbit": "bosdyn_orbit",
+                        "bosdyn.orbit.util": "bosdyn_orbit",
+                    }
+                ),
+            ],
+            output_path,
+        )
+
+        # "bosdyn.orbit"/"bosdyn.orbit.util" are redundant with the top-level
+        # "bosdyn" -> "bosdyn_orbit" entry and get collapsed away, but
+        # "bosdyn.client" must survive since it resolves to a different wheel
+        # than the top-level "bosdyn" entry.
+        self.assertEqual(
+            {
+                "bosdyn": "bosdyn_orbit",
+                "bosdyn.client": "bosdyn_client",
+            },
+            json.loads(output_path.read_text()),
+        )
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/news/4135.fixed.md b/news/4135.fixed.md
new file mode 100644
index 0000000..f0be693
--- /dev/null
+++ b/news/4135.fixed.md
@@ -0,0 +1,3 @@
+(gazelle) Fixed a regression from version 1.8.0 which broke module map
+generation for old-style namespace packages.
+([#4135](https://github.com/bazel-contrib/rules_python/pull/4135)).