diff --git a/README.md b/README.md index 92ce73e..b4df4ff 100644 --- a/README.md +++ b/README.md @@ -165,10 +165,11 @@ twyn run --dependency-file The following dependency file formats are supported: - `requirements.txt` -- `poetry.lock` +- `poetry.lock` (<1.5, >=1.5) - `uv.lock` - `package-lock.json` (v1, v2, v3) - `yarn.lock` (v1, v2) +- `pnpm-lock.yaml` (v9) ### Check dependencies introduced through the CLI diff --git a/src/twyn/base/constants.py b/src/twyn/base/constants.py index f41f4e2..6879240 100644 --- a/src/twyn/base/constants.py +++ b/src/twyn/base/constants.py @@ -30,6 +30,7 @@ "poetry.lock": dependency_parser.PoetryLockParser, "uv.lock": dependency_parser.UvLockParser, "package-lock.json": dependency_parser.PackageLockJsonParser, + "pnpm-lock.yaml": dependency_parser.PnpmLockParser, "yarn.lock": dependency_parser.YarnLockParser, } """Mapping of dependency file names to their parser classes.""" diff --git a/src/twyn/dependency_managers/managers.py b/src/twyn/dependency_managers/managers.py index 60c226d..76878fc 100644 --- a/src/twyn/dependency_managers/managers.py +++ b/src/twyn/dependency_managers/managers.py @@ -4,6 +4,7 @@ from twyn.dependency_managers.exceptions import NoMatchingDependencyManagerError from twyn.dependency_parser.parsers.constants import ( PACKAGE_LOCK_JSON, + PNPM_LOCK_YAML, POETRY_LOCK, REQUIREMENTS_TXT, UV_LOCK, @@ -48,7 +49,7 @@ def get_alternative_source(self, sources: dict[str, str]) -> str | None: npm_dependency_manager = DependencyManager( name="npm", trusted_packages_source=TopNpmReference, - dependency_files={PACKAGE_LOCK_JSON, YARN_LOCK}, + dependency_files={PACKAGE_LOCK_JSON, YARN_LOCK, PNPM_LOCK_YAML}, trusted_packages_manager=TrustedNpmPackageManager, ) pypi_dependency_manager = DependencyManager( diff --git a/src/twyn/dependency_parser/__init__.py b/src/twyn/dependency_parser/__init__.py index d9670af..1726af6 100644 --- a/src/twyn/dependency_parser/__init__.py +++ b/src/twyn/dependency_parser/__init__.py @@ -2,7 +2,15 @@ from twyn.dependency_parser.parsers.lock_parser import PoetryLockParser, UvLockParser from twyn.dependency_parser.parsers.package_lock_json import PackageLockJsonParser +from twyn.dependency_parser.parsers.pnpm_lock_parser import PnpmLockParser from twyn.dependency_parser.parsers.requirements_txt_parser import RequirementsTxtParser from twyn.dependency_parser.parsers.yarn_lock_parser import YarnLockParser -__all__ = ["RequirementsTxtParser", "PoetryLockParser", "UvLockParser", "PackageLockJsonParser", "YarnLockParser"] +__all__ = [ + "RequirementsTxtParser", + "PoetryLockParser", + "UvLockParser", + "PackageLockJsonParser", + "YarnLockParser", + "PnpmLockParser", +] diff --git a/src/twyn/dependency_parser/lock_parser.py b/src/twyn/dependency_parser/lock_parser.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/twyn/dependency_parser/package_lock_json.py b/src/twyn/dependency_parser/package_lock_json.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/twyn/dependency_parser/parsers/constants.py b/src/twyn/dependency_parser/parsers/constants.py index d384917..445fa0f 100644 --- a/src/twyn/dependency_parser/parsers/constants.py +++ b/src/twyn/dependency_parser/parsers/constants.py @@ -4,6 +4,9 @@ PACKAGE_LOCK_JSON = "package-lock.json" """Filename for npm package lock files.""" +PNPM_LOCK_YAML = "pnpm-lock.yaml" +"""Filename for pnpm lock files.""" + POETRY_LOCK = "poetry.lock" """Filename for Poetry dependency lock files.""" diff --git a/src/twyn/dependency_parser/parsers/pnpm_lock_parser.py b/src/twyn/dependency_parser/parsers/pnpm_lock_parser.py new file mode 100644 index 0000000..b2c7d8a --- /dev/null +++ b/src/twyn/dependency_parser/parsers/pnpm_lock_parser.py @@ -0,0 +1,137 @@ +import re +from typing import Any + +import yaml +from typing_extensions import override + +from twyn.dependency_parser.parsers.abstract_parser import AbstractParser +from twyn.dependency_parser.parsers.constants import PNPM_LOCK_YAML + + +class PnpmLockParser(AbstractParser): + """Parser for pnpm-lock.yaml dependencies.""" + + _SCOPED_PACKAGE_PATTERN = re.compile(r"^(@[^@]+/[^@]+)@") + _REGULAR_PACKAGE_PATTERN = re.compile(r"^([^@]+)@") + _PACKAGE_NAME_VALIDATION_PATTERN = re.compile(r"^(@[a-zA-Z0-9_.-]+\/)?[a-zA-Z0-9_.-]+$") + + def __init__(self, file_path: str = PNPM_LOCK_YAML) -> None: + super().__init__(file_path) + + @override + def parse(self) -> set[str]: + """Parse pnpm-lock.yaml file and extract package names.""" + content = self.file_handler.read() + try: + data = yaml.safe_load(content) + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML in pnpm-lock.yaml: {e}") from e + + if not isinstance(data, dict): + return set() + + packages: set[str] = set() + + # Parse root-level dependencies (if any) + self._extract_from_pnpm_dependencies(data.get("dependencies", {}), packages) + self._extract_from_pnpm_dependencies(data.get("devDependencies", {}), packages) + self._extract_from_pnpm_dependencies(data.get("optionalDependencies", {}), packages) + + # Parse packages section (all resolved packages) + self._extract_from_packages(data.get("packages", {}), packages) + + # Parse importers section (workspace packages) + importers = data.get("importers", {}) + if isinstance(importers, dict): + for importer_data in importers.values(): + if isinstance(importer_data, dict): + self._extract_from_pnpm_dependencies(importer_data.get("dependencies", {}), packages) + self._extract_from_pnpm_dependencies(importer_data.get("devDependencies", {}), packages) + self._extract_from_pnpm_dependencies(importer_data.get("optionalDependencies", {}), packages) + + return packages + + def _extract_from_pnpm_dependencies(self, deps: dict[str, Any], packages: set[str]) -> None: + """Extract package names from pnpm-lock.yaml dependencies section. + + In pnpm-lock.yaml, dependencies have the format: + { + "package-name": { + "specifier": "^1.0.0", + "version": "1.0.0" + } + } + """ + if not isinstance(deps, dict): + return + + for dep_name, _dep_info in deps.items(): + if isinstance(dep_name, str): + # Handle scoped packages (@scope/package) + normalized_name = self._normalize_package_name(dep_name) + if normalized_name: + packages.add(normalized_name) + + def _extract_from_packages(self, packages_section: dict[str, Any], packages: set[str]) -> None: + """Extract package names from packages section. + + In pnpm-lock.yaml v9+, package keys are in the format: + - @scope/package@1.0.0 + - package@1.0.0 + """ + if not isinstance(packages_section, dict): + return + + for package_key in packages_section: + if isinstance(package_key, str): + # Package keys are in format: package@version or @scope/package@version + package_name = self._extract_package_name_from_key(package_key) + if package_name: + normalized_name = self._normalize_package_name(package_name) + if normalized_name: + packages.add(normalized_name) + + def _extract_package_name_from_key(self, package_key: str) -> str | None: + """Extract package name from package key. + + In pnpm-lock.yaml v9+, package keys are in formats like: + - @scope/package@1.0.0 + - package@1.0.0 + - @scope/package@1.0.0_peer-dep@1.0.0 + + """ + if not package_key: + return None + + # Handle scoped packages (@scope/package@version) + if package_key.startswith("@"): + # Pattern: @scope/package@version or @scope/package@version_peer-deps + match = self._SCOPED_PACKAGE_PATTERN.match(package_key) + if match: + return match.group(1) + else: + # Pattern: package@version or package@version_peer-deps + match = self._REGULAR_PACKAGE_PATTERN.match(package_key) + if match: + return match.group(1) + + return None + + def _normalize_package_name(self, name: str) -> str | None: + """Normalize package name by removing invalid characters.""" + if not name or not isinstance(name, str): + return None + + # Remove any trailing whitespace and validate + normalized = name.strip() + + # Basic validation for npm package names + if not normalized or len(normalized) > 214: + return None + + # Check for valid npm package name characters + # Allow letters, numbers, hyphens, underscores, dots, and scoped packages + if not self._PACKAGE_NAME_VALIDATION_PATTERN.match(normalized): + return None + + return normalized diff --git a/tests/conftest.py b/tests/conftest.py index 31f0836..4f15fe9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -452,6 +452,91 @@ def yarn_lock_file_v2(tmp_path: Path) -> Iterator[Path]: yield tmp_file +@pytest.fixture +def pnpm_lock_file_v9(tmp_path: Path) -> Iterator[Path]: + """PNPM lock file v9 with comprehensive structure.""" + pnpm_lock_file = tmp_path / "pnpm-lock.yaml" + data = """ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + lodash: + specifier: ^4.17.21 + version: 4.17.21 + devDependencies: + '@babel/core': + specifier: ^7.20.0 + version: 7.20.12 + '@types/node': + specifier: ^18.0.0 + version: 18.11.18 + typescript: + specifier: ^4.9.0 + version: 4.9.5 + + packages/workspace-a: + dependencies: + express: + specifier: ^4.18.2 + version: 4.18.2 + devDependencies: + debug: + specifier: ^4.3.4 + version: 4.3.4 + +packages: + 'react@18.2.0': + resolution: {integrity: sha512-react-hash} + engines: {node: '>=0.10.0'} + + 'react-dom@18.2.0': + resolution: {integrity: sha512-react-dom-hash} + peerDependencies: + react: ^18.2.0 + + 'lodash@4.17.21': + resolution: {integrity: sha512-lodash-hash} + + '@babel/core@7.20.12': + resolution: {integrity: sha512-babel-core-hash} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.20.2': + resolution: {integrity: sha512-babel-helper-hash} + engines: {node: '>=6.9.0'} + + '@types/node@18.11.18': + resolution: {integrity: sha512-types-node-hash} + + 'typescript@4.9.5': + resolution: {integrity: sha512-typescript-hash} + engines: {node: '>=4.2.0'} + hasBin: true + + 'express@4.18.2': + resolution: {integrity: sha512-express-hash} + engines: {node: '>= 0.10.0'} + + 'debug@4.3.4': + resolution: {integrity: sha512-debug-hash} + engines: {node: '>=6.0'} + """ + with create_tmp_file(pnpm_lock_file, data) as tmp_file: + yield tmp_file + + @pytest.fixture def package_lock_json_file_with_namespace_typo(tmp_path: Path) -> Iterator[Path]: """NPM package-lock.json file with both namespace and regular package typos.""" diff --git a/tests/dependency_parser/test_dependency_parser.py b/tests/dependency_parser/test_dependency_parser.py index f164283..fdac613 100644 --- a/tests/dependency_parser/test_dependency_parser.py +++ b/tests/dependency_parser/test_dependency_parser.py @@ -2,7 +2,13 @@ from unittest.mock import Mock, patch import pytest -from twyn.dependency_parser import PackageLockJsonParser, PoetryLockParser, RequirementsTxtParser, UvLockParser +from twyn.dependency_parser import ( + PackageLockJsonParser, + PnpmLockParser, + PoetryLockParser, + RequirementsTxtParser, + UvLockParser, +) from twyn.dependency_parser.parsers.abstract_parser import AbstractParser from twyn.dependency_parser.parsers.yarn_lock_parser import YarnLockParser from twyn.file_handler.exceptions import PathIsNotFileError, PathNotFoundError @@ -32,7 +38,7 @@ def test_file_exists(self, mock_exists: Mock, mock_is_file: Mock) -> None: ) def test_raise_for_valid_file( self, mock_is_file: Mock, mock_exists: Mock, file_exists: Mock, is_file, exception: Mock - ): + ) -> None: mock_exists.return_value = file_exists mock_is_file.return_value = is_file @@ -102,3 +108,27 @@ def test_parse_yarn_lock_v2(self, yarn_lock_file_v2: Path) -> None: parser = YarnLockParser(file_path=str(yarn_lock_file_v2)) assert parser.parse() == {"dependencies", "react-dom", "lodash", "version", "cacheKey", "resolution", "react"} + + +class TestPnpmLockParser: + def test_parse_pnpm_lock_v9(self, pnpm_lock_file_v9: Path) -> None: + parser = PnpmLockParser(file_path=str(pnpm_lock_file_v9)) + result = parser.parse() + + # Should find all dependencies from the v9 fixture + expected_packages = { + "react", + "react-dom", + "lodash", + "@babel/core", + "@babel/helper-plugin-utils", + "express", + "debug", + "typescript", + "@types/node", + } + assert expected_packages == result + + # Should not include workspace projects + assert "test-project" not in result + assert "my-workspace" not in result