Skip to content

Commit 700654f

Browse files
authored
CM-55551: npm avoid restore when alternative lockfile exists (#367)
1 parent 7749b00 commit 700654f

File tree

10 files changed

+497
-11
lines changed

10 files changed

+497
-11
lines changed

cycode/cli/files_collector/sca/go/restore_go_dependencies.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]:
4444
def get_lock_file_name(self) -> str:
4545
return GO_RESTORE_FILE_NAME
4646

47-
def get_lock_file_names(self) -> str:
47+
def get_lock_file_names(self) -> list[str]:
4848
return [self.get_lock_file_name()]

cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]:
4141
def get_lock_file_name(self) -> str:
4242
return BUILD_GRADLE_DEP_TREE_FILE_NAME
4343

44-
def get_lock_file_names(self) -> str:
44+
def get_lock_file_names(self) -> list[str]:
4545
return [self.get_lock_file_name()]
4646

4747
def get_working_directory(self, document: Document) -> Optional[str]:

cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]:
3434
def get_lock_file_name(self) -> str:
3535
return join_paths('target', MAVEN_CYCLONE_DEP_TREE_FILE_NAME)
3636

37-
def get_lock_file_names(self) -> str:
37+
def get_lock_file_names(self) -> list[str]:
3838
return [self.get_lock_file_name()]
3939

4040
def try_restore_dependencies(self, document: Document) -> Optional[Document]:

cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import os
2+
from typing import Optional
23

34
import typer
45

5-
from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies
6+
from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path
67
from cycode.cli.models import Document
8+
from cycode.cli.utils.path_utils import get_file_content
9+
from cycode.logger import get_logger
10+
11+
logger = get_logger('NPM Restore Dependencies')
712

813
NPM_PROJECT_FILE_EXTENSIONS = ['.json']
914
NPM_LOCK_FILE_NAME = 'package-lock.json'
10-
NPM_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME, 'yarn.lock', 'pnpm-lock.yaml', 'deno.lock']
15+
# Alternative lockfiles that should prevent npm install from running
16+
ALTERNATIVE_LOCK_FILES = ['yarn.lock', 'pnpm-lock.yaml', 'deno.lock']
17+
NPM_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME, *ALTERNATIVE_LOCK_FILES]
1118
NPM_MANIFEST_FILE_NAME = 'package.json'
1219

1320

@@ -18,6 +25,127 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int)
1825
def is_project(self, document: Document) -> bool:
1926
return any(document.path.endswith(ext) for ext in NPM_PROJECT_FILE_EXTENSIONS)
2027

28+
def _resolve_manifest_directory(self, document: Document) -> Optional[str]:
29+
"""Resolve the directory containing the manifest file.
30+
31+
Uses the same path resolution logic as get_manifest_file_path() to ensure consistency.
32+
Falls back to absolute_path or document.path if needed.
33+
34+
Returns:
35+
Directory path if resolved, None otherwise.
36+
"""
37+
manifest_file_path = self.get_manifest_file_path(document)
38+
manifest_dir = os.path.dirname(manifest_file_path) if manifest_file_path else None
39+
40+
# Fallback: if manifest_dir is empty or root, try using absolute_path or document.path
41+
if not manifest_dir or manifest_dir == os.sep or manifest_dir == '.':
42+
base_path = document.absolute_path if document.absolute_path else document.path
43+
if base_path:
44+
manifest_dir = os.path.dirname(base_path)
45+
46+
return manifest_dir
47+
48+
def _find_existing_lockfile(self, manifest_dir: str) -> tuple[Optional[str], list[str]]:
49+
"""Find the first existing lockfile in the manifest directory.
50+
51+
Args:
52+
manifest_dir: Directory to search for lockfiles.
53+
54+
Returns:
55+
Tuple of (lockfile_path if found, list of checked lockfiles with status).
56+
"""
57+
lock_file_paths = [os.path.join(manifest_dir, lock_file_name) for lock_file_name in NPM_LOCK_FILE_NAMES]
58+
59+
existing_lock_file = None
60+
checked_lockfiles = []
61+
for lock_file_path in lock_file_paths:
62+
lock_file_name = os.path.basename(lock_file_path)
63+
exists = os.path.isfile(lock_file_path)
64+
checked_lockfiles.append(f'{lock_file_name}: {"exists" if exists else "not found"}')
65+
if exists:
66+
existing_lock_file = lock_file_path
67+
break
68+
69+
return existing_lock_file, checked_lockfiles
70+
71+
def _create_document_from_lockfile(self, document: Document, lockfile_path: str) -> Optional[Document]:
72+
"""Create a Document from an existing lockfile.
73+
74+
Args:
75+
document: Original document (package.json).
76+
lockfile_path: Path to the existing lockfile.
77+
78+
Returns:
79+
Document with lockfile content if successful, None otherwise.
80+
"""
81+
lock_file_name = os.path.basename(lockfile_path)
82+
logger.info(
83+
'Skipping npm install: using existing lockfile, %s',
84+
{'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path},
85+
)
86+
87+
relative_restore_file_path = build_dep_tree_path(document.path, lock_file_name)
88+
restore_file_content = get_file_content(lockfile_path)
89+
90+
if restore_file_content is not None:
91+
logger.debug(
92+
'Successfully loaded lockfile content, %s',
93+
{'path': document.path, 'lockfile': lock_file_name, 'content_size': len(restore_file_content)},
94+
)
95+
return Document(relative_restore_file_path, restore_file_content, self.is_git_diff)
96+
97+
logger.warning(
98+
'Lockfile exists but could not read content, %s',
99+
{'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path},
100+
)
101+
return None
102+
103+
def try_restore_dependencies(self, document: Document) -> Optional[Document]:
104+
"""Override to prevent npm install when any lockfile exists.
105+
106+
The base class uses document.absolute_path which might be None or incorrect.
107+
We need to use the same path resolution logic as get_manifest_file_path()
108+
to ensure we check for lockfiles in the correct location.
109+
110+
If any lockfile exists (package-lock.json, pnpm-lock.yaml, yarn.lock, deno.lock),
111+
we use it directly without running npm install to avoid generating invalid lockfiles.
112+
"""
113+
# Check if this is a project file first (same as base class caller does)
114+
if not self.is_project(document):
115+
logger.debug('Skipping restore: document is not recognized as npm project, %s', {'path': document.path})
116+
return None
117+
118+
# Resolve the manifest directory
119+
manifest_dir = self._resolve_manifest_directory(document)
120+
if not manifest_dir:
121+
logger.debug(
122+
'Cannot determine manifest directory, proceeding with base class restore flow, %s',
123+
{'path': document.path},
124+
)
125+
return super().try_restore_dependencies(document)
126+
127+
# Check for existing lockfiles
128+
logger.debug(
129+
'Checking for existing lockfiles in directory, %s', {'directory': manifest_dir, 'path': document.path}
130+
)
131+
existing_lock_file, checked_lockfiles = self._find_existing_lockfile(manifest_dir)
132+
133+
logger.debug(
134+
'Lockfile check results, %s',
135+
{'path': document.path, 'checked_lockfiles': ', '.join(checked_lockfiles)},
136+
)
137+
138+
# If any lockfile exists, use it directly without running npm install
139+
if existing_lock_file:
140+
return self._create_document_from_lockfile(document, existing_lock_file)
141+
142+
# No lockfile exists, proceed with the normal restore flow which will run npm install
143+
logger.info(
144+
'No existing lockfile found, proceeding with npm install to generate package-lock.json, %s',
145+
{'path': document.path, 'directory': manifest_dir, 'checked_lockfiles': ', '.join(checked_lockfiles)},
146+
)
147+
return super().try_restore_dependencies(document)
148+
21149
def get_commands(self, manifest_file_path: str) -> list[list[str]]:
22150
return [
23151
[
@@ -37,9 +165,16 @@ def get_restored_lock_file_name(self, restore_file_path: str) -> str:
37165
def get_lock_file_name(self) -> str:
38166
return NPM_LOCK_FILE_NAME
39167

40-
def get_lock_file_names(self) -> str:
168+
def get_lock_file_names(self) -> list[str]:
41169
return NPM_LOCK_FILE_NAMES
42170

43171
@staticmethod
44172
def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str:
45-
return manifest_file_path.replace(os.sep + NPM_MANIFEST_FILE_NAME, '')
173+
# Remove package.json from the path
174+
if manifest_file_path.endswith(NPM_MANIFEST_FILE_NAME):
175+
# Use os.path.dirname to handle both Unix (/) and Windows (\) separators
176+
# This is cross-platform and handles edge cases correctly
177+
dir_path = os.path.dirname(manifest_file_path)
178+
# If dir_path is empty or just '.', return an empty string (package.json in current dir)
179+
return dir_path if dir_path and dir_path != '.' else ''
180+
return manifest_file_path

cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]:
2020
def get_lock_file_name(self) -> str:
2121
return NUGET_LOCK_FILE_NAME
2222

23-
def get_lock_file_names(self) -> str:
23+
def get_lock_file_names(self) -> list[str]:
2424
return [self.get_lock_file_name()]

cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]:
1515
def get_lock_file_name(self) -> str:
1616
return RUBY_LOCK_FILE_NAME
1717

18-
def get_lock_file_names(self) -> str:
18+
def get_lock_file_names(self) -> list[str]:
1919
return [self.get_lock_file_name()]

cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]:
1515
def get_lock_file_name(self) -> str:
1616
return SBT_LOCK_FILE_NAME
1717

18-
def get_lock_file_names(self) -> str:
18+
def get_lock_file_names(self) -> list[str]:
1919
return [self.get_lock_file_name()]

cycode/cli/files_collector/sca/sca_file_collector.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,11 @@ def _add_dependencies_tree_documents(
153153
continue
154154

155155
if restore_dependencies_document.path in documents_to_add:
156-
logger.debug('Duplicate document on restore for path: %s', restore_dependencies_document.path)
156+
# Lockfile was already collected during file discovery, so we skip adding it again
157+
logger.debug(
158+
'Lockfile already exists in scan, skipping duplicate document, %s',
159+
{'path': restore_dependencies_document.path, 'source': 'restore'},
160+
)
157161
else:
158162
logger.debug('Adding dependencies tree document, %s', restore_dependencies_document.path)
159163
documents_to_add[restore_dependencies_document.path] = restore_dependencies_document

tests/cli/files_collector/sca/npm/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)