diff --git a/cachi2/core/package_managers/yarn_classic/utils.py b/cachi2/core/package_managers/yarn_classic/utils.py new file mode 100644 index 000000000..6ca6725d6 --- /dev/null +++ b/cachi2/core/package_managers/yarn_classic/utils.py @@ -0,0 +1,103 @@ +from collections import deque +from typing import Any + +from pyarn.lockfile import Package as PYarnPackage + +from cachi2.core.package_managers.yarn_classic.project import PackageJson, YarnLock +from cachi2.core.package_managers.yarn_classic.workspaces import Workspace +from cachi2.core.rooted_path import RootedPath + + +def find_non_dev_deps( + main_package_json: PackageJson, + yarn_lock: YarnLock, + workspaces: list[Workspace], +) -> set[str]: + """ + Identify all non-dev dependencies in the root package and its workspaces. + + A dependency is classified as "non-dev" if: + - It is listed in `dependencies`, `peerDependencies`, or `optionalDependencies` + of any `package.json` file. + - It is a transitive dependency of another non-dev dependency. + + A dependency is classified as "dev" if: + - It is listed in `devDependencies` of any `package.json` file. + - It is a transitive dependency of a dev dependency. + + This function ensures that a dependency appearing as both a dev and non-dev + dependency is marked as non-dev. + """ + # TODO: PackageJson object should be an attribute of the Workspace object + ws_package_jsons = [ + PackageJson.from_file(RootedPath(ws.path / "package.json")) for ws in workspaces + ] + all_package_jsons = [main_package_json] + ws_package_jsons + + root_dep_ids = [] + for package_json in all_package_jsons: + for dep_type in ["dependencies", "peerDependencies", "optionalDependencies"]: + for name, version in package_json.data.get(dep_type, {}).items(): + root_dep_id = f"{name}@{version}" + root_dep_ids.append(root_dep_id) + + expanded_yarn_lock = _expand_yarn_lock_keys(yarn_lock) + + all_dep_ids = set() + for root_dep_id in root_dep_ids: + transitive_dep_ids = _find_transitive_deps(root_dep_id, expanded_yarn_lock) + all_dep_ids.update(transitive_dep_ids) + + return all_dep_ids + + +def _expand_yarn_lock_keys(yarn_lock: YarnLock) -> dict[str, dict[str, Any]]: + """ + Expand compound keys in the yarn.lock dictionary into individual keys. + + In the original yarn.lock dictionary, a single key may represent multiple package names, + separated by commas (e.g., "package-a@^1.0.0, package-b@^2.0.0"). These are referred to + as compound keys, where multiple keys share the same value (N:1 mapping). + + This function splits such compound keys into individual keys, creating a new dictionary + where each key maps directly to the same shared value as in the original dictionary. + The result is a dictionary with only one-to-one (1:1) key-value mappings. + + Note: This function does not copy the values. The newly created individual keys will + all reference the same original value object. + """ + + def split_multi_key(dep_id: str) -> list[str]: + return dep_id.replace('"', "").split(", ") + + result = {} + for dep_id in yarn_lock.data.keys(): + for key in split_multi_key(dep_id): + result[key] = yarn_lock.data[dep_id] + + return result + + +def _find_transitive_deps( + root_dep_id: str, + expanded_yarn_lock: dict[str, dict[str, Any]], +) -> set[str]: + """Perform a breadth-first search (BFS) to find all transitive dependencies of a given root dependency.""" + bfs_queue = deque([root_dep_id]) + visited = set() + + while bfs_queue: + current_dep_id = bfs_queue.popleft() + visited.add(current_dep_id) + + dep_info = expanded_yarn_lock.get(current_dep_id) + if dep_info is None: + continue + + package = PYarnPackage.from_dict(current_dep_id, dep_info) + for name, version in package.dependencies.items(): + new_dep_id = f"{name}@{version}" + if new_dep_id not in visited: + bfs_queue.append(new_dep_id) + + return visited diff --git a/tests/unit/package_managers/yarn_classic/test_utils.py b/tests/unit/package_managers/yarn_classic/test_utils.py new file mode 100644 index 000000000..3f0a4aaa2 --- /dev/null +++ b/tests/unit/package_managers/yarn_classic/test_utils.py @@ -0,0 +1,69 @@ +from unittest import mock + +from cachi2.core.package_managers.yarn_classic.project import PackageJson +from cachi2.core.package_managers.yarn_classic.utils import find_non_dev_deps +from cachi2.core.rooted_path import RootedPath + +PACKAGE_JSON_CONTENT = """ +{ + "name": "project", + "dependencies": { + "main-dep1": "^1.0.0" + }, + "optionalDependencies": { + "optional-dep1": "^2.0.0" + }, + "peerDependencies": { + "peer-dep1": "^3.0.0" + }, + "devDependencies": { + "dev-dep1": "^4.0.0" + } +} +""" + + +@mock.patch("cachi2.core.package_managers.yarn_classic.project.YarnLock") +@mock.patch("cachi2.core.package_managers.yarn_classic.utils._expand_yarn_lock_keys") +def test_find_non_dev_deps( + mock_expand_yarn_lock_keys: mock.Mock, + mock_yarn_lock: mock.Mock, + rooted_tmp_path: RootedPath, +) -> None: + rooted_tmp_path.join_within_root("package.json").path.write_text(PACKAGE_JSON_CONTENT) + package_json = PackageJson.from_file(rooted_tmp_path.join_within_root("package.json")) + + mock_expand_yarn_lock_keys.return_value = { + # dependencies + "main-dep1@^1.0.0": { + "version": "1.0.0", + "dependencies": {"sub-dep1": "^1.1.0"}, + }, + # optional dependencies + "optional-dep1@^2.0.0": { + "version": "2.0.0", + "dependencies": {"multi-dep1": "^4.0.0", "multi-dep2": "^4.0.0"}, + }, + "multi-dep1@^4.0.0, multi-dep2@^4.0.0": {"version": "5.0.0", "dependencies": {}}, + # peer dependencies + "peer-dep1@^3.0.0": {"version": "3.0.0", "dependencies": {}}, + # transitive dependencies + "sub-dep1@^1.1.0": {"version": "1.1.0", "dependencies": {"sub-dep11": "^1.1.1"}}, + "sub-dep11@^1.1.1": {"version": "1.1.1", "dependencies": {}}, + # dev dependencies + "dev-dep1@^4.0.0": {"version": "4.0.0", "dependencies": {}}, + } + + result = find_non_dev_deps(package_json, mock_yarn_lock, []) + + mock_expand_yarn_lock_keys.assert_called_once_with(mock_yarn_lock) + assert result == { + "main-dep1@^1.0.0", + "optional-dep1@^2.0.0", + "multi-dep1@^4.0.0", + "multi-dep2@^4.0.0", + "peer-dep1@^3.0.0", + "sub-dep1@^1.1.0", + "sub-dep11@^1.1.1", + # no dev dependencies + }