blob: 8680df34897bb35bf8a318027fb784493fe9972d [file] [log] [blame]
"Helper utility for working with pnpm lockfile"
load("@bazel_skylib//lib:dicts.bzl", "dicts")
load(":utils.bzl", "utils")
def gather_transitive_closure(packages, package, no_optional, cache = {}):
"""Walk the dependency tree, collecting the transitive closure of dependencies and their versions.
This is needed to resolve npm dependency cycles.
Note: Starlark expressly forbids recursion and infinite loops, so we need to iterate over a large range of numbers,
where each iteration takes one item from the stack, and possibly adds many new items to the stack.
Args:
packages: dictionary from pnpm lock
package: the package to collect deps for
no_optional: whether to exclude optionalDependencies
cache: a dictionary of results from previous invocations
Returns:
A dictionary of transitive dependencies, mapping package names to dependent versions.
"""
root_package = packages[package]
transitive_closure = {}
transitive_closure[root_package["name"]] = [root_package["version"]]
stack = [_get_package_info_deps(root_package, no_optional)]
iteration_max = 999999
for i in range(0, iteration_max + 1):
if not len(stack):
break
if i == iteration_max:
msg = "gather_transitive_closure exhausted the iteration limit of {} - please report this issue".format(iteration_max)
fail(msg)
deps = stack.pop()
for name in deps.keys():
version = deps[name]
if version.startswith("npm:"):
# an aliased dependency
package_key = version[4:]
name, version = package_key.rsplit("@", 1)
elif version not in packages:
package_key = utils.package_key(name, version)
else:
package_key = version
transitive_closure[name] = transitive_closure.get(name, [])
if version in transitive_closure[name]:
continue
transitive_closure[name].append(version)
if version.startswith("link:"):
# we don't need to drill down through first-party links for the transitive closure since there are no cycles
# allowed in first-party links
continue
if package_key in cache:
# Already computed for this dep, merge the cached results
for transitive_name in cache[package_key].keys():
transitive_closure[transitive_name] = transitive_closure.get(transitive_name, [])
for transitive_version in cache[package_key][transitive_name]:
if transitive_version not in transitive_closure[transitive_name]:
transitive_closure[transitive_name].append(transitive_version)
elif package_key in packages:
# Recurse into the next level of dependencies
stack.append(_get_package_info_deps(packages[package_key], no_optional))
else:
msg = "Unknown package key: {} ({} @ {}) in {}".format(package_key, name, version, packages.keys())
fail(msg)
return utils.sorted_map(transitive_closure)
def _get_package_info_deps(package_info, no_optional):
return package_info["dependencies"] if no_optional else dicts.add(package_info["dependencies"], package_info["optional_dependencies"])
def translate_to_transitive_closure(importers, packages, prod = False, dev = False, no_optional = False):
"""Implementation detail of translate_package_lock, converts pnpm-lock to a different dictionary with more data.
Args:
importers: workspace projects (pnpm "importers")
packages: all package info by name
prod: If true, only install dependencies
dev: If true, only install devDependencies
no_optional: If true, optionalDependencies are not installed
Returns:
Nested dictionary suitable for further processing in our repository rule
"""
# Packages resolved to a different version
package_version_map = {}
# tarbal versions
for package_key, package_info in packages.items():
if package_info["resolution"].get("tarball", None) and package_info["resolution"]["tarball"].startswith("file:"):
package_version_map[package_key] = package_info
# Collect deps of each importer (workspace projects)
importers_deps = {}
for importPath in importers.keys():
lock_importer = importers[importPath]
prod_deps = {} if dev else lock_importer.get("dependencies")
dev_deps = {} if prod else lock_importer.get("dev_dependencies")
opt_deps = {} if no_optional else lock_importer.get("optional_dependencies")
deps = dicts.add(prod_deps, opt_deps)
all_deps = dicts.add(prod_deps, dev_deps, opt_deps)
# Package versions mapped to alternate versions
for info in package_version_map.values():
if info["name"] in deps:
deps[info["name"]] = info["version"]
if info["name"] in all_deps:
all_deps[info["name"]] = info["version"]
importers_deps[importPath] = {
# deps this importer should pass on if it is linked as a first-party package; this does
# not include devDependencies
"deps": deps,
# all deps of this importer to link in the node_modules folder of that Bazel package and
# make available to all build targets; this includes devDependencies
"all_deps": all_deps,
}
# Collect transitive dependencies for each package
cache = {}
for package in packages.keys():
transitive_closure = gather_transitive_closure(
packages,
package,
no_optional,
cache,
)
packages[package]["transitive_closure"] = transitive_closure
cache[package] = transitive_closure
return (importers_deps, packages)