cycode 3.25.1.dev3__py3-none-any.whl → 3.25.1.dev4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cycode/__init__.py +1 -1
- cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py +10 -3
- cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py +1 -1
- cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +32 -13
- cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py +10 -3
- cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py +10 -3
- cycode/cli/files_collector/sca/npm/workspace/__init__.py +49 -0
- cycode/cli/files_collector/sca/npm/workspace/coverage.py +142 -0
- cycode/cli/files_collector/sca/npm/workspace/files.py +38 -0
- cycode/cli/files_collector/sca/npm/workspace/globs.py +116 -0
- cycode/cli/files_collector/sca/npm/workspace/names.py +16 -0
- cycode/cli/files_collector/sca/npm/workspace/resolvers.py +294 -0
- cycode/cli/files_collector/sca/sca_file_collector.py +3 -0
- cycode/cli/utils/path_utils.py +31 -4
- {cycode-3.25.1.dev3.dist-info → cycode-3.25.1.dev4.dist-info}/METADATA +1 -1
- {cycode-3.25.1.dev3.dist-info → cycode-3.25.1.dev4.dist-info}/RECORD +19 -13
- {cycode-3.25.1.dev3.dist-info → cycode-3.25.1.dev4.dist-info}/WHEEL +0 -0
- {cycode-3.25.1.dev3.dist-info → cycode-3.25.1.dev4.dist-info}/entry_points.txt +0 -0
- {cycode-3.25.1.dev3.dist-info → cycode-3.25.1.dev4.dist-info}/licenses/LICENCE +0 -0
cycode/__init__.py
CHANGED
|
@@ -5,4 +5,4 @@ import time as _time
|
|
|
5
5
|
# end-to-end scan duration from the moment the user actually triggered it.
|
|
6
6
|
_BOOT_WALL: float = _time.time()
|
|
7
7
|
|
|
8
|
-
__version__ = '3.25.1.
|
|
8
|
+
__version__ = '3.25.1.dev4' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
|
|
@@ -6,15 +6,19 @@ from typing import Optional
|
|
|
6
6
|
import typer
|
|
7
7
|
|
|
8
8
|
from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path
|
|
9
|
+
from cycode.cli.files_collector.sca.npm.workspace import (
|
|
10
|
+
BUN_LOCK_FILE_NAME,
|
|
11
|
+
MANIFEST_FILE_NAME,
|
|
12
|
+
is_covered_workspace_member,
|
|
13
|
+
)
|
|
9
14
|
from cycode.cli.models import Document
|
|
10
|
-
from cycode.cli.utils.path_utils import get_file_content
|
|
15
|
+
from cycode.cli.utils.path_utils import get_file_content, get_scan_roots_from_context
|
|
11
16
|
from cycode.cli.utils.shell_executor import shell
|
|
12
17
|
from cycode.logger import get_logger
|
|
13
18
|
|
|
14
19
|
logger = get_logger('Bun Restore Dependencies')
|
|
15
20
|
|
|
16
|
-
BUN_MANIFEST_FILE_NAME =
|
|
17
|
-
BUN_LOCK_FILE_NAME = 'bun.lock'
|
|
21
|
+
BUN_MANIFEST_FILE_NAME = MANIFEST_FILE_NAME
|
|
18
22
|
|
|
19
23
|
# Only Bun >=1.2 produces the text-based `bun.lock` lockfile that we parse.
|
|
20
24
|
# Older Bun versions emit a binary `bun.lockb`, which is not supported.
|
|
@@ -61,6 +65,9 @@ class RestoreBunDependencies(BaseRestoreDependencies):
|
|
|
61
65
|
if manifest_dir and (Path(manifest_dir) / BUN_LOCK_FILE_NAME).is_file():
|
|
62
66
|
return True
|
|
63
67
|
|
|
68
|
+
if is_covered_workspace_member(manifest_dir, document.path, get_scan_roots_from_context(self.ctx)):
|
|
69
|
+
return False
|
|
70
|
+
|
|
64
71
|
return _indicates_bun(document.content)
|
|
65
72
|
|
|
66
73
|
def _is_supported_bun_version(self) -> bool:
|
|
@@ -4,6 +4,7 @@ from typing import Optional
|
|
|
4
4
|
import typer
|
|
5
5
|
|
|
6
6
|
from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path
|
|
7
|
+
from cycode.cli.files_collector.sca.npm.workspace import DENO_LOCK_FILE_NAME
|
|
7
8
|
from cycode.cli.models import Document
|
|
8
9
|
from cycode.cli.utils.path_utils import get_file_content
|
|
9
10
|
from cycode.logger import get_logger
|
|
@@ -11,7 +12,6 @@ from cycode.logger import get_logger
|
|
|
11
12
|
logger = get_logger('Deno Restore Dependencies')
|
|
12
13
|
|
|
13
14
|
DENO_MANIFEST_FILE_NAMES = ('deno.json', 'deno.jsonc')
|
|
14
|
-
DENO_LOCK_FILE_NAME = 'deno.lock'
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class RestoreDenoDependencies(BaseRestoreDependencies):
|
|
@@ -3,15 +3,25 @@ from pathlib import Path
|
|
|
3
3
|
import typer
|
|
4
4
|
|
|
5
5
|
from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies
|
|
6
|
+
from cycode.cli.files_collector.sca.npm.workspace import (
|
|
7
|
+
BUN_LOCK_FILE_NAME,
|
|
8
|
+
DENO_LOCK_FILE_NAME,
|
|
9
|
+
MANIFEST_FILE_NAME,
|
|
10
|
+
NPM_LOCK_FILE_NAME,
|
|
11
|
+
NPM_SHRINKWRAP_FILE_NAME,
|
|
12
|
+
PNPM_LOCK_FILE_NAME,
|
|
13
|
+
YARN_LOCK_FILE_NAME,
|
|
14
|
+
is_covered_workspace_member,
|
|
15
|
+
)
|
|
6
16
|
from cycode.cli.models import Document
|
|
17
|
+
from cycode.cli.utils.path_utils import get_scan_roots_from_context
|
|
7
18
|
from cycode.logger import get_logger
|
|
8
19
|
|
|
9
20
|
logger = get_logger('NPM Restore Dependencies')
|
|
10
21
|
|
|
11
|
-
NPM_MANIFEST_FILE_NAME =
|
|
12
|
-
NPM_LOCK_FILE_NAME = 'package-lock.json'
|
|
22
|
+
NPM_MANIFEST_FILE_NAME = MANIFEST_FILE_NAME
|
|
13
23
|
# These lockfiles indicate another package manager owns the project — NPM should not run
|
|
14
|
-
_ALTERNATIVE_LOCK_FILES = (
|
|
24
|
+
_ALTERNATIVE_LOCK_FILES = (YARN_LOCK_FILE_NAME, PNPM_LOCK_FILE_NAME, DENO_LOCK_FILE_NAME, BUN_LOCK_FILE_NAME)
|
|
15
25
|
|
|
16
26
|
|
|
17
27
|
class RestoreNpmDependencies(BaseRestoreDependencies):
|
|
@@ -24,6 +34,9 @@ class RestoreNpmDependencies(BaseRestoreDependencies):
|
|
|
24
34
|
Yarn and pnpm projects are handled by their dedicated handlers, which run before
|
|
25
35
|
this one in the handler list. This handler is the npm fallback.
|
|
26
36
|
|
|
37
|
+
A manifest is also declined when a lockfile further up already resolves it, whichever
|
|
38
|
+
package manager wrote that lockfile; see the workspace package for how that is decided.
|
|
39
|
+
|
|
27
40
|
NOTE: this guard only excludes a project when an alternative lockfile is *physically
|
|
28
41
|
present on disk*. It does not inspect the `packageManager`/`engines` signal in
|
|
29
42
|
package.json. So a project that declares e.g. `packageManager: "bun@..."` (or pnpm)
|
|
@@ -37,16 +50,18 @@ class RestoreNpmDependencies(BaseRestoreDependencies):
|
|
|
37
50
|
return False
|
|
38
51
|
|
|
39
52
|
manifest_dir = self.get_manifest_dir(document)
|
|
40
|
-
if manifest_dir:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
if not manifest_dir:
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
for lock_file in _ALTERNATIVE_LOCK_FILES:
|
|
57
|
+
if (Path(manifest_dir) / lock_file).is_file():
|
|
58
|
+
logger.debug(
|
|
59
|
+
'Skipping npm restore: alternative lockfile detected, %s',
|
|
60
|
+
{'path': document.path, 'lockfile': lock_file},
|
|
61
|
+
)
|
|
62
|
+
return False
|
|
48
63
|
|
|
49
|
-
return
|
|
64
|
+
return not is_covered_workspace_member(manifest_dir, document.path, get_scan_roots_from_context(self.ctx))
|
|
50
65
|
|
|
51
66
|
def get_commands(self, manifest_file_path: str) -> list[list[str]]:
|
|
52
67
|
return [
|
|
@@ -65,7 +80,11 @@ class RestoreNpmDependencies(BaseRestoreDependencies):
|
|
|
65
80
|
return NPM_LOCK_FILE_NAME
|
|
66
81
|
|
|
67
82
|
def get_lock_file_names(self) -> list[str]:
|
|
68
|
-
return [NPM_LOCK_FILE_NAME]
|
|
83
|
+
return [NPM_LOCK_FILE_NAME, NPM_SHRINKWRAP_FILE_NAME]
|
|
84
|
+
|
|
85
|
+
def get_restored_lock_file_name(self, restore_file_path: str) -> str:
|
|
86
|
+
name = Path(restore_file_path).name
|
|
87
|
+
return name if name in self.get_lock_file_names() else self.get_lock_file_name()
|
|
69
88
|
|
|
70
89
|
@staticmethod
|
|
71
90
|
def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str:
|
|
@@ -5,14 +5,18 @@ from typing import Optional
|
|
|
5
5
|
import typer
|
|
6
6
|
|
|
7
7
|
from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path
|
|
8
|
+
from cycode.cli.files_collector.sca.npm.workspace import (
|
|
9
|
+
MANIFEST_FILE_NAME,
|
|
10
|
+
PNPM_LOCK_FILE_NAME,
|
|
11
|
+
is_covered_workspace_member,
|
|
12
|
+
)
|
|
8
13
|
from cycode.cli.models import Document
|
|
9
|
-
from cycode.cli.utils.path_utils import get_file_content
|
|
14
|
+
from cycode.cli.utils.path_utils import get_file_content, get_scan_roots_from_context
|
|
10
15
|
from cycode.logger import get_logger
|
|
11
16
|
|
|
12
17
|
logger = get_logger('Pnpm Restore Dependencies')
|
|
13
18
|
|
|
14
|
-
PNPM_MANIFEST_FILE_NAME =
|
|
15
|
-
PNPM_LOCK_FILE_NAME = 'pnpm-lock.yaml'
|
|
19
|
+
PNPM_MANIFEST_FILE_NAME = MANIFEST_FILE_NAME
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
def _indicates_pnpm(package_json_content: Optional[str]) -> bool:
|
|
@@ -44,6 +48,9 @@ class RestorePnpmDependencies(BaseRestoreDependencies):
|
|
|
44
48
|
if manifest_dir and (Path(manifest_dir) / PNPM_LOCK_FILE_NAME).is_file():
|
|
45
49
|
return True
|
|
46
50
|
|
|
51
|
+
if is_covered_workspace_member(manifest_dir, document.path, get_scan_roots_from_context(self.ctx)):
|
|
52
|
+
return False
|
|
53
|
+
|
|
47
54
|
return _indicates_pnpm(document.content)
|
|
48
55
|
|
|
49
56
|
def try_restore_dependencies(self, document: Document) -> Optional[Document]:
|
|
@@ -5,14 +5,18 @@ from typing import Optional
|
|
|
5
5
|
import typer
|
|
6
6
|
|
|
7
7
|
from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path
|
|
8
|
+
from cycode.cli.files_collector.sca.npm.workspace import (
|
|
9
|
+
MANIFEST_FILE_NAME,
|
|
10
|
+
YARN_LOCK_FILE_NAME,
|
|
11
|
+
is_covered_workspace_member,
|
|
12
|
+
)
|
|
8
13
|
from cycode.cli.models import Document
|
|
9
|
-
from cycode.cli.utils.path_utils import get_file_content
|
|
14
|
+
from cycode.cli.utils.path_utils import get_file_content, get_scan_roots_from_context
|
|
10
15
|
from cycode.logger import get_logger
|
|
11
16
|
|
|
12
17
|
logger = get_logger('Yarn Restore Dependencies')
|
|
13
18
|
|
|
14
|
-
YARN_MANIFEST_FILE_NAME =
|
|
15
|
-
YARN_LOCK_FILE_NAME = 'yarn.lock'
|
|
19
|
+
YARN_MANIFEST_FILE_NAME = MANIFEST_FILE_NAME
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
def _indicates_yarn(package_json_content: Optional[str]) -> bool:
|
|
@@ -44,6 +48,9 @@ class RestoreYarnDependencies(BaseRestoreDependencies):
|
|
|
44
48
|
if manifest_dir and (Path(manifest_dir) / YARN_LOCK_FILE_NAME).is_file():
|
|
45
49
|
return True
|
|
46
50
|
|
|
51
|
+
if is_covered_workspace_member(manifest_dir, document.path, get_scan_roots_from_context(self.ctx)):
|
|
52
|
+
return False
|
|
53
|
+
|
|
47
54
|
return _indicates_yarn(document.content)
|
|
48
55
|
|
|
49
56
|
def try_restore_dependencies(self, document: Document) -> Optional[Document]:
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Deciding whether a lockfile above a manifest already resolves that manifest's dependencies.
|
|
2
|
+
|
|
3
|
+
The lockfile is the authority wherever it can name its members; the manifest's workspaces globs
|
|
4
|
+
are a fallback for the formats that cannot. See resolvers.py for the per-format readers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from cycode.cli.files_collector.sca.npm.workspace import coverage as _coverage
|
|
8
|
+
from cycode.cli.files_collector.sca.npm.workspace import globs as _globs
|
|
9
|
+
from cycode.cli.files_collector.sca.npm.workspace import resolvers as _resolvers
|
|
10
|
+
from cycode.cli.files_collector.sca.npm.workspace.coverage import (
|
|
11
|
+
WorkspaceCoverage,
|
|
12
|
+
find_covering_workspace,
|
|
13
|
+
is_covered_workspace_member,
|
|
14
|
+
)
|
|
15
|
+
from cycode.cli.files_collector.sca.npm.workspace.names import (
|
|
16
|
+
BUN_BINARY_LOCK_FILE_NAME,
|
|
17
|
+
BUN_LOCK_FILE_NAME,
|
|
18
|
+
DENO_LOCK_FILE_NAME,
|
|
19
|
+
MANIFEST_FILE_NAME,
|
|
20
|
+
NPM_LOCK_FILE_NAME,
|
|
21
|
+
NPM_SHRINKWRAP_FILE_NAME,
|
|
22
|
+
PNPM_LOCK_FILE_NAME,
|
|
23
|
+
YARN_LOCK_FILE_NAME,
|
|
24
|
+
)
|
|
25
|
+
from cycode.cli.files_collector.sca.npm.workspace.resolvers import MEMBER_RESOLVERS
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def clear_cache() -> None:
|
|
29
|
+
"""Drop every memo, so one scan never inherits another scan's view of the filesystem."""
|
|
30
|
+
_resolvers.clear_cache()
|
|
31
|
+
_globs.clear_cache()
|
|
32
|
+
_coverage.clear_cache()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
'BUN_BINARY_LOCK_FILE_NAME',
|
|
37
|
+
'BUN_LOCK_FILE_NAME',
|
|
38
|
+
'DENO_LOCK_FILE_NAME',
|
|
39
|
+
'MANIFEST_FILE_NAME',
|
|
40
|
+
'MEMBER_RESOLVERS',
|
|
41
|
+
'NPM_LOCK_FILE_NAME',
|
|
42
|
+
'NPM_SHRINKWRAP_FILE_NAME',
|
|
43
|
+
'PNPM_LOCK_FILE_NAME',
|
|
44
|
+
'YARN_LOCK_FILE_NAME',
|
|
45
|
+
'WorkspaceCoverage',
|
|
46
|
+
'clear_cache',
|
|
47
|
+
'find_covering_workspace',
|
|
48
|
+
'is_covered_workspace_member',
|
|
49
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Walking up from a manifest to the lockfile that already resolves it."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, NamedTuple, Optional
|
|
6
|
+
|
|
7
|
+
from cycode.cli.files_collector.sca.npm.workspace.files import logger, resolved_path
|
|
8
|
+
from cycode.cli.files_collector.sca.npm.workspace.globs import declares_workspace_member
|
|
9
|
+
from cycode.cli.files_collector.sca.npm.workspace.resolvers import MEMBER_RESOLVERS
|
|
10
|
+
from cycode.cli.utils.path_utils import is_sub_path
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
|
|
15
|
+
_GIT_DIR_NAME = '.git'
|
|
16
|
+
|
|
17
|
+
_reported_unscanned_roots: set = set()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def clear_cache() -> None:
|
|
21
|
+
_reported_unscanned_roots.clear()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WorkspaceCoverage(NamedTuple):
|
|
25
|
+
package_manager: str
|
|
26
|
+
lock_file: Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _scan_root_directories(scan_roots: tuple) -> list:
|
|
30
|
+
"""The directory each scanned path stands for; scanning a file scans its directory."""
|
|
31
|
+
directories = []
|
|
32
|
+
for scan_root in scan_roots:
|
|
33
|
+
resolved = resolved_path(scan_root)
|
|
34
|
+
if os.path.isfile(resolved):
|
|
35
|
+
resolved = os.path.dirname(resolved)
|
|
36
|
+
|
|
37
|
+
if resolved:
|
|
38
|
+
directories.append(resolved)
|
|
39
|
+
|
|
40
|
+
return directories
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _containing_scan_roots(manifest_dir: Path, scan_roots: tuple) -> list:
|
|
44
|
+
resolved_manifest_dir = resolved_path(manifest_dir)
|
|
45
|
+
return [
|
|
46
|
+
Path(directory)
|
|
47
|
+
for directory in _scan_root_directories(scan_roots)
|
|
48
|
+
if is_sub_path(directory, resolved_manifest_dir)
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _resolve_walk_boundary(manifest_dir: Path, scan_roots: tuple) -> Optional[Path]:
|
|
53
|
+
for root_dir in manifest_dir.parents:
|
|
54
|
+
if (root_dir / _GIT_DIR_NAME).exists():
|
|
55
|
+
return root_dir
|
|
56
|
+
|
|
57
|
+
containing = _containing_scan_roots(manifest_dir, scan_roots)
|
|
58
|
+
if containing:
|
|
59
|
+
return min(containing, key=lambda scan_root: len(scan_root.parts))
|
|
60
|
+
|
|
61
|
+
if scan_roots:
|
|
62
|
+
return manifest_dir
|
|
63
|
+
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _workspace_root_candidates(manifest_dir: Path, scan_roots: tuple) -> 'Iterator[Path]':
|
|
68
|
+
boundary = _resolve_walk_boundary(manifest_dir, scan_roots)
|
|
69
|
+
resolved_boundary = resolved_path(boundary) if boundary is not None else None
|
|
70
|
+
if resolved_boundary == resolved_path(manifest_dir):
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
for root_dir in manifest_dir.parents:
|
|
74
|
+
yield root_dir
|
|
75
|
+
if resolved_boundary is not None and resolved_path(root_dir) == resolved_boundary:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _find_covering_workspace(manifest_dir: Path, scan_roots: tuple) -> Optional[WorkspaceCoverage]:
|
|
80
|
+
for root_dir in _workspace_root_candidates(manifest_dir, scan_roots):
|
|
81
|
+
member_path = manifest_dir.relative_to(root_dir).as_posix()
|
|
82
|
+
|
|
83
|
+
for resolver in MEMBER_RESOLVERS:
|
|
84
|
+
for lock_file_name in resolver.lock_file_names:
|
|
85
|
+
lock_file = root_dir / lock_file_name
|
|
86
|
+
if not lock_file.is_file():
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
member_names = resolver.resolve(lock_file)
|
|
90
|
+
if member_names is not None:
|
|
91
|
+
if member_path in member_names:
|
|
92
|
+
return WorkspaceCoverage(resolver.package_manager, lock_file)
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
if resolver.may_use_workspace_globs and declares_workspace_member(root_dir, member_path):
|
|
96
|
+
return WorkspaceCoverage(resolver.package_manager, lock_file)
|
|
97
|
+
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def find_covering_workspace(manifest_dir: Optional[str], scan_roots: tuple = ()) -> Optional[WorkspaceCoverage]:
|
|
102
|
+
if not manifest_dir:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
return _find_covering_workspace(Path(manifest_dir), scan_roots)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_inside_scanned_paths(scan_roots: tuple, root_dir: Path) -> bool:
|
|
109
|
+
directories = _scan_root_directories(scan_roots)
|
|
110
|
+
if not directories:
|
|
111
|
+
logger.debug('No scanned paths in context; treating the workspace root as scanned, %s', {'root': str(root_dir)})
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
resolved_root_dir = resolved_path(root_dir)
|
|
115
|
+
return any(is_sub_path(directory, resolved_root_dir) for directory in directories)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_covered_workspace_member(manifest_dir: Optional[str], document_path: str, scan_roots: tuple = ()) -> bool:
|
|
119
|
+
coverage = find_covering_workspace(manifest_dir, scan_roots)
|
|
120
|
+
if coverage is None:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
details = {
|
|
124
|
+
'path': document_path,
|
|
125
|
+
'root_lockfile': str(coverage.lock_file),
|
|
126
|
+
'workspace': coverage.package_manager,
|
|
127
|
+
}
|
|
128
|
+
if _is_inside_scanned_paths(scan_roots, coverage.lock_file.parent):
|
|
129
|
+
logger.debug('Skipping restore: the workspace root lockfile already covers this member, %s', details)
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
report_key = (document_path, str(coverage.lock_file))
|
|
133
|
+
if report_key not in _reported_unscanned_roots:
|
|
134
|
+
_reported_unscanned_roots.add(report_key)
|
|
135
|
+
logger.warning(
|
|
136
|
+
'The workspace root lockfile is outside the scanned path and will not be collected, '
|
|
137
|
+
'so this member is restored on its own. Scan the workspace root for the versions it '
|
|
138
|
+
'actually installs, %s',
|
|
139
|
+
details,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return False
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Filesystem access shared by the readers: identity for caching, and tolerant parsing."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from cycode.cli.utils.path_utils import get_absolute_path
|
|
9
|
+
from cycode.logger import get_logger
|
|
10
|
+
|
|
11
|
+
logger = get_logger('SCA NPM Workspace')
|
|
12
|
+
|
|
13
|
+
FileStamp = tuple[str, int, int]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolved_path(path: object) -> str:
|
|
17
|
+
return os.path.realpath(get_absolute_path(str(path)))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def file_stamp(path: Path) -> Optional[FileStamp]:
|
|
21
|
+
try:
|
|
22
|
+
stat_result = path.stat()
|
|
23
|
+
except OSError:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
return str(path), stat_result.st_mtime_ns, stat_result.st_size
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_json_object(path: Path) -> Optional[dict]:
|
|
30
|
+
try:
|
|
31
|
+
content = json.loads(path.read_text(encoding='UTF-8'))
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
return None
|
|
34
|
+
except (OSError, ValueError) as e:
|
|
35
|
+
logger.debug('Could not read an npm workspace file, %s', {'path': str(path), 'error': e})
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
return content if isinstance(content, dict) else None
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Matching a member against the manifest's workspaces globs.
|
|
2
|
+
|
|
3
|
+
Only reached for lockfiles that cannot name their own members: classic yarn.lock and the
|
|
4
|
+
binary bun.lockb. Every other format answers from the lockfile itself.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import fnmatch
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
|
|
12
|
+
from cycode.cli.files_collector.sca.npm.workspace.files import FileStamp, file_stamp, read_json_object
|
|
13
|
+
from cycode.cli.files_collector.sca.npm.workspace.names import MANIFEST_FILE_NAME
|
|
14
|
+
|
|
15
|
+
_MANIFEST_WORKSPACES_SECTION = 'workspaces'
|
|
16
|
+
_MANIFEST_WORKSPACE_PACKAGES_SECTION = 'packages'
|
|
17
|
+
_NEGATION_PREFIX = '!'
|
|
18
|
+
_GLOBSTAR = '**'
|
|
19
|
+
_SEGMENT_MATCH_CACHE_SIZE = 4096
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _WorkspacePatterns(NamedTuple):
|
|
23
|
+
included: tuple[str, ...]
|
|
24
|
+
excluded: tuple[str, ...]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_EMPTY_WORKSPACE_PATTERNS = _WorkspacePatterns((), ())
|
|
28
|
+
|
|
29
|
+
_workspace_patterns_cache: dict[FileStamp, _WorkspacePatterns] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def clear_cache() -> None:
|
|
33
|
+
_workspace_patterns_cache.clear()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _matches_workspace_pattern(pattern: str, member_path: str) -> bool:
|
|
37
|
+
"""Match a workspace glob against a member path, one path segment at a time.
|
|
38
|
+
|
|
39
|
+
fnmatch is wrong for a whole path because its * crosses a separator, but inside one
|
|
40
|
+
segment there is no separator, so it is exactly right there - and it brings character
|
|
41
|
+
classes with it. Only ** needs handling here, because only ** spans segments.
|
|
42
|
+
"""
|
|
43
|
+
return _match_segments(tuple(pattern.split('/')), tuple(member_path.split('/')))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@lru_cache(maxsize=_SEGMENT_MATCH_CACHE_SIZE)
|
|
47
|
+
def _match_segments(pattern_segments: tuple, path_segments: tuple) -> bool:
|
|
48
|
+
"""Memoised so that several ** in one pattern cannot make this exponential.
|
|
49
|
+
|
|
50
|
+
Each ** tries every split of the remaining path, so without memoisation a pattern such as
|
|
51
|
+
**/**/**/x against a deep tree multiplies out. The arguments are plain tuples of strings,
|
|
52
|
+
so a result can never go stale.
|
|
53
|
+
"""
|
|
54
|
+
if not pattern_segments:
|
|
55
|
+
return not path_segments
|
|
56
|
+
|
|
57
|
+
head, rest = pattern_segments[0], pattern_segments[1:]
|
|
58
|
+
if head == _GLOBSTAR:
|
|
59
|
+
# ** spans zero or more segments, and spanning zero is what makes dir/** match dir
|
|
60
|
+
return any(_match_segments(rest, path_segments[index:]) for index in range(len(path_segments) + 1))
|
|
61
|
+
|
|
62
|
+
if not path_segments:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
return fnmatch.fnmatchcase(path_segments[0], head) and _match_segments(rest, path_segments[1:])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _split_workspace_patterns(declared: list) -> _WorkspacePatterns:
|
|
69
|
+
included = []
|
|
70
|
+
excluded = []
|
|
71
|
+
for entry in declared:
|
|
72
|
+
stripped = entry.strip()
|
|
73
|
+
is_excluded = stripped.startswith(_NEGATION_PREFIX)
|
|
74
|
+
normalized = (stripped[1:] if is_excluded else stripped).strip()
|
|
75
|
+
if normalized.startswith('./'):
|
|
76
|
+
normalized = normalized[2:]
|
|
77
|
+
|
|
78
|
+
normalized = normalized.rstrip('/')
|
|
79
|
+
if not normalized:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if is_excluded:
|
|
83
|
+
excluded.append(normalized)
|
|
84
|
+
else:
|
|
85
|
+
included.append(normalized)
|
|
86
|
+
|
|
87
|
+
return _WorkspacePatterns(tuple(included), tuple(excluded))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _read_manifest_workspace_patterns(root_dir: Path) -> _WorkspacePatterns:
|
|
91
|
+
manifest = root_dir / MANIFEST_FILE_NAME
|
|
92
|
+
stamp = file_stamp(manifest)
|
|
93
|
+
if stamp is None:
|
|
94
|
+
return _EMPTY_WORKSPACE_PATTERNS
|
|
95
|
+
|
|
96
|
+
cached = _workspace_patterns_cache.get(stamp)
|
|
97
|
+
if cached is not None:
|
|
98
|
+
return cached
|
|
99
|
+
|
|
100
|
+
content = read_json_object(manifest)
|
|
101
|
+
workspaces = content.get(_MANIFEST_WORKSPACES_SECTION) if content is not None else None
|
|
102
|
+
if isinstance(workspaces, dict):
|
|
103
|
+
workspaces = workspaces.get(_MANIFEST_WORKSPACE_PACKAGES_SECTION)
|
|
104
|
+
|
|
105
|
+
declared = [entry for entry in workspaces if isinstance(entry, str)] if isinstance(workspaces, list) else []
|
|
106
|
+
patterns = _split_workspace_patterns(declared)
|
|
107
|
+
_workspace_patterns_cache[stamp] = patterns
|
|
108
|
+
return patterns
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def declares_workspace_member(root_dir: Path, member_path: str) -> bool:
|
|
112
|
+
patterns = _read_manifest_workspace_patterns(root_dir)
|
|
113
|
+
if not any(_matches_workspace_pattern(pattern, member_path) for pattern in patterns.included):
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
return not any(_matches_workspace_pattern(pattern, member_path) for pattern in patterns.excluded)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""File names and package-manager identifiers shared across the workspace package."""
|
|
2
|
+
|
|
3
|
+
MANIFEST_FILE_NAME = 'package.json'
|
|
4
|
+
|
|
5
|
+
NPM_PACKAGE_MANAGER = 'npm'
|
|
6
|
+
YARN_PACKAGE_MANAGER = 'yarn'
|
|
7
|
+
PNPM_PACKAGE_MANAGER = 'pnpm'
|
|
8
|
+
BUN_PACKAGE_MANAGER = 'bun'
|
|
9
|
+
|
|
10
|
+
NPM_LOCK_FILE_NAME = 'package-lock.json'
|
|
11
|
+
NPM_SHRINKWRAP_FILE_NAME = 'npm-shrinkwrap.json'
|
|
12
|
+
YARN_LOCK_FILE_NAME = 'yarn.lock'
|
|
13
|
+
PNPM_LOCK_FILE_NAME = 'pnpm-lock.yaml'
|
|
14
|
+
BUN_LOCK_FILE_NAME = 'bun.lock'
|
|
15
|
+
BUN_BINARY_LOCK_FILE_NAME = 'bun.lockb'
|
|
16
|
+
DENO_LOCK_FILE_NAME = 'deno.lock'
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Reading the members a root lockfile resolves, one reader per lockfile format.
|
|
2
|
+
|
|
3
|
+
A reader that returns a set is the authority on that lockfile, including when the set is empty.
|
|
4
|
+
None means the format cannot name its members at all, and only then does may_use_workspace_globs
|
|
5
|
+
decide whether the manifest globs get a say.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from cycode.cli.files_collector.sca.npm.workspace.files import FileStamp, file_stamp, logger, read_json_object
|
|
17
|
+
from cycode.cli.files_collector.sca.npm.workspace.names import (
|
|
18
|
+
BUN_BINARY_LOCK_FILE_NAME,
|
|
19
|
+
BUN_LOCK_FILE_NAME,
|
|
20
|
+
BUN_PACKAGE_MANAGER,
|
|
21
|
+
NPM_LOCK_FILE_NAME,
|
|
22
|
+
NPM_PACKAGE_MANAGER,
|
|
23
|
+
NPM_SHRINKWRAP_FILE_NAME,
|
|
24
|
+
PNPM_LOCK_FILE_NAME,
|
|
25
|
+
PNPM_PACKAGE_MANAGER,
|
|
26
|
+
YARN_LOCK_FILE_NAME,
|
|
27
|
+
YARN_PACKAGE_MANAGER,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_LOCKFILE_PACKAGES_SECTION = 'packages'
|
|
31
|
+
_NODE_MODULES_SEPARATOR = 'node_modules/'
|
|
32
|
+
_PNPM_LOCKFILE_IMPORTERS_SECTION = 'importers'
|
|
33
|
+
_PNPM_LOCKFILE_ROOT_IMPORTER = '.'
|
|
34
|
+
_YAML_COMMENT_PREFIX = '#'
|
|
35
|
+
_YARN_BERRY_MARKER = '__metadata'
|
|
36
|
+
_YARN_RESOLUTION_PREFIX = 'resolution:'
|
|
37
|
+
_YARN_WORKSPACE_PROTOCOL = re.compile(r'@workspace:([^"\',\s]+)')
|
|
38
|
+
_BUN_LOCKFILE_WORKSPACES_SECTION = 'workspaces'
|
|
39
|
+
_TRAILING_COMMA = re.compile(r',(\s*[}\]])')
|
|
40
|
+
|
|
41
|
+
_member_names_cache: dict[FileStamp, Optional[frozenset[str]]] = {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def clear_cache() -> None:
|
|
45
|
+
_member_names_cache.clear()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _normalize_member_path(member_path: str) -> Optional[str]:
|
|
49
|
+
normalized = member_path.strip()
|
|
50
|
+
if normalized.startswith('./'):
|
|
51
|
+
normalized = normalized[2:]
|
|
52
|
+
|
|
53
|
+
normalized = normalized.rstrip('/')
|
|
54
|
+
if not normalized or normalized == _PNPM_LOCKFILE_ROOT_IMPORTER:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
return normalized
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _npm_lockfile_member_names(lock_file: Path) -> Optional[frozenset]:
|
|
61
|
+
content = read_json_object(lock_file)
|
|
62
|
+
packages = content.get(_LOCKFILE_PACKAGES_SECTION) if content is not None else None
|
|
63
|
+
if not isinstance(packages, dict):
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
return frozenset(name for name in packages if name and _NODE_MODULES_SEPARATOR not in name)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_pnpm_importers_section(lock_file: Path) -> str:
|
|
70
|
+
"""Slice out the top-level importers block so a large lockfile is not parsed in full."""
|
|
71
|
+
section = []
|
|
72
|
+
inside = False
|
|
73
|
+
try:
|
|
74
|
+
with lock_file.open(encoding='UTF-8') as lock_file_lines:
|
|
75
|
+
for raw_line in lock_file_lines:
|
|
76
|
+
line = raw_line.rstrip('\n').rstrip('\r')
|
|
77
|
+
if not inside:
|
|
78
|
+
if line.startswith(f'{_PNPM_LOCKFILE_IMPORTERS_SECTION}:'):
|
|
79
|
+
inside = True
|
|
80
|
+
section.append(line)
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
if line.startswith(_YAML_COMMENT_PREFIX):
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if line and not line[0].isspace():
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
section.append(line)
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
return ''
|
|
92
|
+
except (OSError, ValueError) as e:
|
|
93
|
+
logger.debug('Could not read a pnpm lockfile, %s', {'path': str(lock_file), 'error': e})
|
|
94
|
+
return ''
|
|
95
|
+
|
|
96
|
+
return '\n'.join(section)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _pnpm_lockfile_member_names(lock_file: Path) -> Optional[frozenset]:
|
|
100
|
+
section = _read_pnpm_importers_section(lock_file)
|
|
101
|
+
if not section:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
content = yaml.safe_load(section)
|
|
106
|
+
except (ValueError, yaml.YAMLError) as e:
|
|
107
|
+
logger.debug('Could not read a pnpm lockfile, %s', {'path': str(lock_file), 'error': e})
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
importers = content.get(_PNPM_LOCKFILE_IMPORTERS_SECTION) if isinstance(content, dict) else None
|
|
111
|
+
if not isinstance(importers, dict):
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
member_names = set()
|
|
115
|
+
for name in importers:
|
|
116
|
+
if not isinstance(name, str):
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
normalized = _normalize_member_path(name)
|
|
120
|
+
if normalized:
|
|
121
|
+
member_names.add(normalized)
|
|
122
|
+
|
|
123
|
+
# a lockfile whose only importer is the root describes a single package, not a workspace
|
|
124
|
+
return frozenset(member_names) or None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _bun_lockfile_member_names(lock_file: Path) -> Optional[frozenset]:
|
|
128
|
+
"""bun.lock is JSON with trailing commas, and names its members under "workspaces"."""
|
|
129
|
+
try:
|
|
130
|
+
text = lock_file.read_text(encoding='UTF-8')
|
|
131
|
+
except FileNotFoundError:
|
|
132
|
+
return None
|
|
133
|
+
except (OSError, ValueError) as e:
|
|
134
|
+
logger.debug('Could not read a bun lockfile, %s', {'path': str(lock_file), 'error': e})
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
content = json.loads(text)
|
|
139
|
+
except ValueError:
|
|
140
|
+
try:
|
|
141
|
+
content = json.loads(_TRAILING_COMMA.sub(r'\1', text))
|
|
142
|
+
except ValueError as e:
|
|
143
|
+
logger.debug('Could not read a bun lockfile, %s', {'path': str(lock_file), 'error': e})
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
workspaces = content.get(_BUN_LOCKFILE_WORKSPACES_SECTION) if isinstance(content, dict) else None
|
|
147
|
+
if not isinstance(workspaces, dict):
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
member_names = {normalized for normalized in map(_normalize_member_path, workspaces) if normalized}
|
|
151
|
+
return frozenset(member_names)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _yarn_lockfile_member_names(lock_file: Path) -> Optional[frozenset]:
|
|
155
|
+
"""Yarn berry records every member as "<name>@workspace:<path>"; classic yarn records nothing."""
|
|
156
|
+
try:
|
|
157
|
+
text = lock_file.read_text(encoding='UTF-8', errors='replace')
|
|
158
|
+
except FileNotFoundError:
|
|
159
|
+
return None
|
|
160
|
+
except (OSError, ValueError) as e:
|
|
161
|
+
logger.debug('Could not read a yarn lockfile, %s', {'path': str(lock_file), 'error': e})
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
if _YARN_BERRY_MARKER not in text:
|
|
165
|
+
return None # classic: flat, so the manifest globs are the only remaining source
|
|
166
|
+
|
|
167
|
+
member_names = set()
|
|
168
|
+
for line in text.splitlines():
|
|
169
|
+
# every entry carries a resolution naming its real path; the dependency entries carry
|
|
170
|
+
# ranges instead (workspace:^, workspace:*), which are not paths
|
|
171
|
+
if not line.strip().startswith(_YARN_RESOLUTION_PREFIX):
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
for declared_path in _YARN_WORKSPACE_PROTOCOL.findall(line):
|
|
175
|
+
normalized = _normalize_member_path(declared_path)
|
|
176
|
+
if normalized:
|
|
177
|
+
member_names.add(normalized)
|
|
178
|
+
|
|
179
|
+
# berry always records what it installed, so an empty result means this is not a workspace
|
|
180
|
+
return frozenset(member_names)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class WorkspaceMemberResolver(ABC):
|
|
184
|
+
"""Reads, from one root lockfile, the member directories that lockfile resolves."""
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
@abstractmethod
|
|
188
|
+
def package_manager(self) -> str: ...
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
@abstractmethod
|
|
192
|
+
def lock_file_names(self) -> tuple: ...
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def may_use_workspace_globs(self) -> bool:
|
|
196
|
+
"""Whether an unanswerable lockfile may defer to the manifest's workspaces globs.
|
|
197
|
+
|
|
198
|
+
False means the silence is itself an answer: a v1 package-lock predates workspaces, so
|
|
199
|
+
its root is simply not one. True means the format has workspaces but does not record
|
|
200
|
+
them, leaving the globs as the only remaining source.
|
|
201
|
+
"""
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
def resolve(self, lock_file: Path) -> Optional[frozenset]:
|
|
205
|
+
"""Member paths this lockfile resolves, or None when it cannot name them.
|
|
206
|
+
|
|
207
|
+
Caches on the file's identity so one scan parses each root lockfile once.
|
|
208
|
+
"""
|
|
209
|
+
stamp = file_stamp(lock_file)
|
|
210
|
+
if stamp is None:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
if stamp in _member_names_cache:
|
|
214
|
+
return _member_names_cache[stamp]
|
|
215
|
+
|
|
216
|
+
member_names = self._read_member_names(lock_file)
|
|
217
|
+
_member_names_cache[stamp] = member_names
|
|
218
|
+
return member_names
|
|
219
|
+
|
|
220
|
+
@abstractmethod
|
|
221
|
+
def _read_member_names(self, lock_file: Path) -> Optional[frozenset]: ...
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class NpmLockfileResolver(WorkspaceMemberResolver):
|
|
225
|
+
package_manager = NPM_PACKAGE_MANAGER
|
|
226
|
+
lock_file_names = (NPM_LOCK_FILE_NAME, NPM_SHRINKWRAP_FILE_NAME)
|
|
227
|
+
|
|
228
|
+
def _read_member_names(self, lock_file: Path) -> Optional[frozenset]:
|
|
229
|
+
return _npm_lockfile_member_names(lock_file)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class PnpmLockfileResolver(WorkspaceMemberResolver):
|
|
233
|
+
package_manager = PNPM_PACKAGE_MANAGER
|
|
234
|
+
lock_file_names = (PNPM_LOCK_FILE_NAME,)
|
|
235
|
+
|
|
236
|
+
def _read_member_names(self, lock_file: Path) -> Optional[frozenset]:
|
|
237
|
+
return _pnpm_lockfile_member_names(lock_file)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class YarnLockfileResolver(WorkspaceMemberResolver):
|
|
241
|
+
"""Berry names every member; classic is flat and names none, so only classic needs the globs."""
|
|
242
|
+
|
|
243
|
+
package_manager = YARN_PACKAGE_MANAGER
|
|
244
|
+
lock_file_names = (YARN_LOCK_FILE_NAME,)
|
|
245
|
+
may_use_workspace_globs = True
|
|
246
|
+
|
|
247
|
+
def _read_member_names(self, lock_file: Path) -> Optional[frozenset]:
|
|
248
|
+
return _yarn_lockfile_member_names(lock_file)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class BunLockfileResolver(WorkspaceMemberResolver):
|
|
252
|
+
"""Only the text bun.lock names members; the binary bun.lockb is handled as opaque."""
|
|
253
|
+
|
|
254
|
+
package_manager = BUN_PACKAGE_MANAGER
|
|
255
|
+
lock_file_names = (BUN_LOCK_FILE_NAME,)
|
|
256
|
+
|
|
257
|
+
def _read_member_names(self, lock_file: Path) -> Optional[frozenset]:
|
|
258
|
+
return _bun_lockfile_member_names(lock_file)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class OpaqueLockfileResolver(WorkspaceMemberResolver):
|
|
262
|
+
"""A lockfile we cannot read members from at all, so the manifest globs decide."""
|
|
263
|
+
|
|
264
|
+
may_use_workspace_globs = True
|
|
265
|
+
|
|
266
|
+
def __init__(self, package_manager: str, lock_file_names: tuple) -> None:
|
|
267
|
+
self._package_manager = package_manager
|
|
268
|
+
self._lock_file_names = lock_file_names
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def package_manager(self) -> str:
|
|
272
|
+
return self._package_manager
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def lock_file_names(self) -> tuple:
|
|
276
|
+
return self._lock_file_names
|
|
277
|
+
|
|
278
|
+
def _read_member_names(self, lock_file: Path) -> Optional[frozenset]:
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# Order is precedence: the first lockfile that resolves the member wins.
|
|
283
|
+
#
|
|
284
|
+
# deno.lock is deliberately absent. The glob fallback reads the manifest's workspaces field,
|
|
285
|
+
# which deno does not use - it declares members in deno.json - so a match there would be
|
|
286
|
+
# meaningless. A deno.lock beside a manifest still stops the npm fallback, through the
|
|
287
|
+
# alternative-lockfile guard in the npm handler.
|
|
288
|
+
MEMBER_RESOLVERS = (
|
|
289
|
+
NpmLockfileResolver(),
|
|
290
|
+
YarnLockfileResolver(),
|
|
291
|
+
PnpmLockfileResolver(),
|
|
292
|
+
BunLockfileResolver(),
|
|
293
|
+
OpaqueLockfileResolver(BUN_PACKAGE_MANAGER, (BUN_BINARY_LOCK_FILE_NAME,)),
|
|
294
|
+
)
|
|
@@ -15,6 +15,7 @@ from cycode.cli.files_collector.sca.npm.restore_deno_dependencies import Restore
|
|
|
15
15
|
from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import RestoreNpmDependencies
|
|
16
16
|
from cycode.cli.files_collector.sca.npm.restore_pnpm_dependencies import RestorePnpmDependencies
|
|
17
17
|
from cycode.cli.files_collector.sca.npm.restore_yarn_dependencies import RestoreYarnDependencies
|
|
18
|
+
from cycode.cli.files_collector.sca.npm.workspace import clear_cache as clear_npm_workspace_cache
|
|
18
19
|
from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies
|
|
19
20
|
from cycode.cli.files_collector.sca.php.restore_composer_dependencies import RestoreComposerDependencies
|
|
20
21
|
from cycode.cli.files_collector.sca.python.restore_pip_dependencies import RestorePipDependencies
|
|
@@ -186,6 +187,8 @@ def _add_dependencies_tree_documents(
|
|
|
186
187
|
{'documents_count': len(documents_to_scan), 'is_git_diff': is_git_diff},
|
|
187
188
|
)
|
|
188
189
|
|
|
190
|
+
clear_npm_workspace_cache()
|
|
191
|
+
|
|
189
192
|
documents_to_add: dict[str, Document] = {document.path: document for document in documents_to_scan}
|
|
190
193
|
restore_dependencies_list = _get_restore_handlers(ctx, is_git_diff)
|
|
191
194
|
|
cycode/cli/utils/path_utils.py
CHANGED
|
@@ -152,11 +152,38 @@ def concat_unique_id(filename: str, unique_id: str) -> str:
|
|
|
152
152
|
return str(PurePosixPath(unique_id, *_to_relative_posix_parts(filename)))
|
|
153
153
|
|
|
154
154
|
|
|
155
|
+
def _as_scan_root(value: object) -> Optional[str]:
|
|
156
|
+
if isinstance(value, (str, os.PathLike)):
|
|
157
|
+
return os.fspath(value) or None
|
|
158
|
+
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_scan_roots_from_context(ctx: typer.Context) -> tuple[str, ...]:
|
|
163
|
+
"""Every path the user asked to scan, in the order they were given.
|
|
164
|
+
|
|
165
|
+
Typer declares these arguments as Path, so callers must not assume str.
|
|
166
|
+
"""
|
|
167
|
+
params = getattr(ctx, 'params', None)
|
|
168
|
+
if not isinstance(params, dict):
|
|
169
|
+
return ()
|
|
170
|
+
|
|
171
|
+
scan_roots = []
|
|
172
|
+
|
|
173
|
+
single_root = _as_scan_root(params.get('path'))
|
|
174
|
+
if single_root:
|
|
175
|
+
scan_roots.append(single_root)
|
|
176
|
+
|
|
177
|
+
paths = params.get('paths')
|
|
178
|
+
if isinstance(paths, (list, tuple)):
|
|
179
|
+
scan_roots.extend(root for root in (_as_scan_root(entry) for entry in paths) if root)
|
|
180
|
+
|
|
181
|
+
return tuple(dict.fromkeys(scan_roots))
|
|
182
|
+
|
|
183
|
+
|
|
155
184
|
def get_path_from_context(ctx: typer.Context) -> Optional[str]:
|
|
156
|
-
|
|
157
|
-
if
|
|
158
|
-
path = ctx.params['paths'][0]
|
|
159
|
-
return path
|
|
185
|
+
scan_roots = get_scan_roots_from_context(ctx)
|
|
186
|
+
return scan_roots[0] if scan_roots else None
|
|
160
187
|
|
|
161
188
|
|
|
162
189
|
def normalize_file_path(path: str) -> str:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
cycode/__init__.py,sha256=
|
|
1
|
+
cycode/__init__.py,sha256=maSJqqkxg3160B3eJEs3M1LWs_m8n1e6k8181bbO1IA,396
|
|
2
2
|
cycode/__main__.py,sha256=Z3bD5yrA7yPvAChcADQrqCaZd0ChGI1gdiwALwbWJ6U,104
|
|
3
3
|
cycode/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
cycode/cli/app.py,sha256=AlR2durAEbsa47PDfIj7JtMvJDWA_Dq6wPtVuMJYSCs,10250
|
|
@@ -124,11 +124,17 @@ cycode/cli/files_collector/sca/maven/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeu
|
|
|
124
124
|
cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py,sha256=2W5Y8yrSIV7o4rCUtNsqO2YFImvcY5TnabzTbStiaBo,3261
|
|
125
125
|
cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py,sha256=zObNN8n6yUriNVB3ZdvAkoKXXrMvzU9Lpd5lNhVo_so,4003
|
|
126
126
|
cycode/cli/files_collector/sca/npm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
127
|
-
cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py,sha256=
|
|
128
|
-
cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py,sha256=
|
|
129
|
-
cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py,sha256=
|
|
130
|
-
cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py,sha256=
|
|
131
|
-
cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py,sha256=
|
|
127
|
+
cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py,sha256=65mrzCwQTLpm7f5VUxLqIP_yiX5BFj66WM85Z4W2H9E,4857
|
|
128
|
+
cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py,sha256=II3JBROuh1cqPHHsFYU78ej-mUIgqN9iwVB-czWAtIo,1811
|
|
129
|
+
cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py,sha256=zRoXhkJnqaic3Bd7_3rqtzqP404kyP2NuJM8LpgfePk,4023
|
|
130
|
+
cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py,sha256=FkYpbA1cFKHHudhedAzxGIm1L2oBB-mtxQHAB6O0ATs,2979
|
|
131
|
+
cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py,sha256=qy-5uEBkw5c4xmjb5r6Cvx84XeS7fxdWcWSHIixM38w,2974
|
|
132
|
+
cycode/cli/files_collector/sca/npm/workspace/__init__.py,sha256=G0b_a6ZrgOnqpicf0ZvXZ4Zvs5LH-uBkqi8LzRigl7s,1611
|
|
133
|
+
cycode/cli/files_collector/sca/npm/workspace/coverage.py,sha256=JLpsASXCiTgdFZX5xFN1jPqplFSGMU05qG28hJiXXTw,5039
|
|
134
|
+
cycode/cli/files_collector/sca/npm/workspace/files.py,sha256=uEnqs0TBRfaQ0C_Elqj1lzoNIU-mG4hqN_fS3g3Y59Q,1035
|
|
135
|
+
cycode/cli/files_collector/sca/npm/workspace/globs.py,sha256=TBh6UHHN4Z391Ap3TmANJ5HrPLWcOnQuitoCm2Amoz0,4231
|
|
136
|
+
cycode/cli/files_collector/sca/npm/workspace/names.py,sha256=mL6lp73EFemCuHqeg1GoDHa6GR0cH52sjcfmkgpaXVs,510
|
|
137
|
+
cycode/cli/files_collector/sca/npm/workspace/resolvers.py,sha256=5WhhlACE9NYaQRyfOfFM64ScruQ7bvORlbmuU7J8QQI,10295
|
|
132
138
|
cycode/cli/files_collector/sca/nuget/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
133
139
|
cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py,sha256=wUsuO_JusyIPTBfocHAr48L7rdYtP1B86N0nS4FXdXg,1123
|
|
134
140
|
cycode/cli/files_collector/sca/php/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -142,7 +148,7 @@ cycode/cli/files_collector/sca/ruby/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQ
|
|
|
142
148
|
cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py,sha256=Vqswcxte9YjGnvIm9oZ8r91jNyhuiYDf1mouaTaLg3U,694
|
|
143
149
|
cycode/cli/files_collector/sca/sbt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
144
150
|
cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py,sha256=cfBUR4iQcfplX_O2QPjYh2wSyBteze8wZcT_dG9d1d4,709
|
|
145
|
-
cycode/cli/files_collector/sca/sca_file_collector.py,sha256=
|
|
151
|
+
cycode/cli/files_collector/sca/sca_file_collector.py,sha256=IKDV-rEXTse7bQuJapKWN_mWlGjug2mkAbb3KyFYpkE,10895
|
|
146
152
|
cycode/cli/files_collector/walk_ignore.py,sha256=nvOM6oDmT2SxSI4pU-bLlc9LwTgkfTd2egse69ixf3g,2464
|
|
147
153
|
cycode/cli/files_collector/zip_documents.py,sha256=FMzbA2Vog7Zl_ntizNQJK8AFqoGu0QlPIMIBpgmBiVI,1852
|
|
148
154
|
cycode/cli/logger.py,sha256=mlaYEQGYd582fTCc3SC3cFMj0PKTB6EsaI12Q4VL1z8,65
|
|
@@ -183,7 +189,7 @@ cycode/cli/utils/git_proxy.py,sha256=FPHMBiyLFK9X9vKYpKySRKJH6Dc9Cb3nO241Q95dASE
|
|
|
183
189
|
cycode/cli/utils/host_info.py,sha256=Clln3qCJR00rw6HndD0bAIv329n_bOEx42TKW6FqXFQ,6379
|
|
184
190
|
cycode/cli/utils/ignore_utils.py,sha256=cODqhnOHA2kRo8rMY0YcmcKkmXNPOC9UTCmFu62RRqE,15567
|
|
185
191
|
cycode/cli/utils/jwt_utils.py,sha256=EGI-0CKhCGY8hIcZ9b9diq9hqtOUf8Ha8ukeVJIf974,818
|
|
186
|
-
cycode/cli/utils/path_utils.py,sha256=
|
|
192
|
+
cycode/cli/utils/path_utils.py,sha256=lRFVhmzW1XGoC7U-_-2Lx_vfiSgo1IhGCEfARW3Y6oQ,6493
|
|
187
193
|
cycode/cli/utils/progress_bar.py,sha256=bKBWHHdZsVkdDdWMJLfgLGR0cBYeB44P_DpRM8pvWqU,9528
|
|
188
194
|
cycode/cli/utils/scan_batch.py,sha256=5xKGVDVqoRxdKhuZkK11x4QrNqKmU20Q83E_fy8Nndk,5188
|
|
189
195
|
cycode/cli/utils/scan_utils.py,sha256=_VkZ7maLVST6J8dsqDsKYWQj2EVfqI3iPPsA1QJOGEU,1259
|
|
@@ -217,8 +223,8 @@ cycode/cyclient/report_client.py,sha256=Scq30NeJPzgXv0hPLO1U05AdE9i_2iu6cIrSKpEJ
|
|
|
217
223
|
cycode/cyclient/scan_client.py,sha256=DqAZ7u6Z_cvw9A9RlLkAQUgLRwPCCAsUq5U9umt4F7Y,16955
|
|
218
224
|
cycode/cyclient/scan_config_base.py,sha256=mXsPZGYCtp85rv5GIige40yQZXuRcEKUW-VQJ0vgFzk,1201
|
|
219
225
|
cycode/logger.py,sha256=EfZGRK6VC5rE_LAjIcRrHFiQCueylCDXoG6bvGkrIME,2111
|
|
220
|
-
cycode-3.25.1.
|
|
221
|
-
cycode-3.25.1.
|
|
222
|
-
cycode-3.25.1.
|
|
223
|
-
cycode-3.25.1.
|
|
224
|
-
cycode-3.25.1.
|
|
226
|
+
cycode-3.25.1.dev4.dist-info/METADATA,sha256=Lf-JTQlrq2jGCZcRrGL1uQJexg0UkInWT_mOjm7A5bk,96824
|
|
227
|
+
cycode-3.25.1.dev4.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
228
|
+
cycode-3.25.1.dev4.dist-info/entry_points.txt,sha256=iDcVJM8ByLElVgvBgtYxDjw1kT7O8Mo0LcWZIT5L3Ig,45
|
|
229
|
+
cycode-3.25.1.dev4.dist-info/licenses/LICENCE,sha256=2Wx4N6mD_4xB7-E3hPkZ3MPhpJy__k_I8MaCSO-PDRo,1068
|
|
230
|
+
cycode-3.25.1.dev4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|