Skip to content

Commit ad30007

Browse files
authored
Support yarn dependency tree (#268)
* Support yarn dependency tree * Fix tox error * Retry yarn when npm fails --------- Signed-off-by: 석지영/책임연구원/SW공학(연)Open Source TP <[email protected]>
1 parent aff93ae commit ad30007

File tree

6 files changed

+306
-44
lines changed

6 files changed

+306
-44
lines changed

src/fosslight_dependency/_analyze_dependency.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import fosslight_dependency.constant as const
99
from fosslight_dependency.package_manager.Pypi import Pypi
1010
from fosslight_dependency.package_manager.Npm import Npm
11+
from fosslight_dependency.package_manager.Yarn import Yarn
1112
from fosslight_dependency.package_manager.Maven import Maven
1213
from fosslight_dependency.package_manager.Gradle import Gradle
1314
from fosslight_dependency.package_manager.Pub import Pub
@@ -32,11 +33,15 @@ def analyze_dependency(package_manager_name, input_dir, output_dir, pip_activate
3233
ret = True
3334
package_dep_item_list = []
3435
cover_comment = ''
36+
npm_fallback_to_yarn = False
3537

3638
if package_manager_name == const.PYPI:
3739
package_manager = Pypi(input_dir, output_dir, pip_activate_cmd, pip_deactivate_cmd)
38-
elif package_manager_name == const.NPM or package_manager_name == const.YARN:
40+
elif package_manager_name == const.NPM:
3941
package_manager = Npm(input_dir, output_dir)
42+
npm_fallback_to_yarn = True
43+
elif package_manager_name == const.YARN:
44+
package_manager = Yarn(input_dir, output_dir)
4045
elif package_manager_name == const.MAVEN:
4146
package_manager = Maven(input_dir, output_dir, output_custom_dir)
4247
elif package_manager_name == const.GRADLE:
@@ -66,14 +71,32 @@ def analyze_dependency(package_manager_name, input_dir, output_dir, pip_activate
6671
else:
6772
logger.error(f"Not supported package manager name: {package_manager_name}")
6873
ret = False
69-
return ret, package_dep_item_list
74+
return ret, package_dep_item_list, cover_comment, package_manager_name
7075

7176
if manifest_file_name:
7277
package_manager.set_manifest_file(manifest_file_name)
7378

7479
if direct:
7580
package_manager.set_direct_dependencies(direct)
7681
ret = package_manager.run_plugin()
82+
83+
if not ret and npm_fallback_to_yarn:
84+
logger.warning("Npm analysis failed. Attempting to use Yarn as fallback...")
85+
del package_manager
86+
package_manager = Yarn(input_dir, output_dir)
87+
package_manager_name = const.YARN
88+
89+
if manifest_file_name:
90+
package_manager.set_manifest_file(manifest_file_name)
91+
if direct:
92+
package_manager.set_direct_dependencies(direct)
93+
94+
ret = package_manager.run_plugin()
95+
if ret:
96+
logger.info("Successfully switched to Yarn")
97+
else:
98+
logger.error("Yarn also failed")
99+
77100
if ret:
78101
if direct:
79102
package_manager.parse_direct_dependencies()
@@ -100,4 +123,4 @@ def analyze_dependency(package_manager_name, input_dir, output_dir, pip_activate
100123

101124
del package_manager
102125

103-
return ret, package_dep_item_list, cover_comment
126+
return ret, package_dep_item_list, cover_comment, package_manager_name

src/fosslight_dependency/constant.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
PYPI: ['requirements.txt', 'setup.py', 'pyproject.toml'],
3333
PNPM: 'pnpm-lock.yaml',
3434
NPM: 'package.json',
35+
YARN: 'yarn.lock',
3536
MAVEN: 'pom.xml',
3637
GRADLE: 'build.gradle',
3738
PUB: 'pubspec.yaml',

src/fosslight_dependency/package_manager/Maven.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,13 @@ def run_plugin(self):
4646
ret = True
4747

4848
if not os.path.isfile(self.input_file_name):
49-
self.is_run_plugin = True
5049
pom_backup = 'pom.xml_backup'
5150

5251
ret = self.add_plugin_in_pom(pom_backup)
5352
if ret:
54-
self.run_maven_plugin()
53+
ret_plugin = self.run_maven_plugin()
54+
if ret_plugin:
55+
self.is_run_plugin = True
5556

5657
if os.path.isfile(pom_backup):
5758
shutil.move(pom_backup, const.SUPPORT_PACKAE.get(self.package_manager_name))
@@ -133,6 +134,7 @@ def clean_run_maven_plugin_output(self):
133134
shutil.rmtree(top_path)
134135

135136
def run_maven_plugin(self):
137+
ret_plugin = True
136138
logger.info('Run maven license scanning plugin with temporary pom.xml')
137139
current_mode = ''
138140
if os.path.isfile('mvnw') or os.path.isfile('mvnw.cmd'):
@@ -148,21 +150,24 @@ def run_maven_plugin(self):
148150
ret = subprocess.call(cmd, shell=True)
149151
if ret != 0:
150152
logger.error(f"Failed to run maven plugin: {cmd}")
153+
ret_plugin = False
151154

152-
cmd = f"{cmd_mvn} dependency:tree"
153-
try:
154-
ret_txt = subprocess.check_output(cmd, text=True, shell=True)
155-
if ret_txt is not None:
156-
self.parse_dependency_tree(ret_txt)
157-
self.set_direct_dependencies(True)
158-
else:
159-
logger.error(f"Failed to run: {cmd}")
155+
if ret_plugin:
156+
cmd = f"{cmd_mvn} dependency:tree"
157+
try:
158+
ret_txt = subprocess.check_output(cmd, text=True, shell=True)
159+
if ret_txt is not None:
160+
self.parse_dependency_tree(ret_txt)
161+
self.set_direct_dependencies(True)
162+
else:
163+
logger.error(f"Failed to run: {cmd}")
164+
self.set_direct_dependencies(False)
165+
except Exception as e:
166+
logger.error(f"Failed to run '{cmd}': {e}")
160167
self.set_direct_dependencies(False)
161-
except Exception as e:
162-
logger.error(f"Failed to run '{cmd}': {e}")
163-
self.set_direct_dependencies(False)
164168
if current_mode:
165169
change_file_mode(cmd_mvn, current_mode)
170+
return ret_plugin
166171

167172
def create_dep_stack(self, dep_line):
168173
dep_stack = []

src/fosslight_dependency/package_manager/Npm.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,8 @@ def start_license_checker(self):
5353
self.flag_tmp_node_modules = True
5454
cmd_ret = subprocess.call(npm_install_cmd, shell=True)
5555
if cmd_ret != 0:
56-
logger.warning(f"{npm_install_cmd} returns an error. Trying yarn as fallback...")
57-
yarn_install_cmd = 'yarn install --production --ignore-scripts'
58-
cmd_ret = subprocess.call(yarn_install_cmd, shell=True)
59-
if cmd_ret != 0:
60-
logger.error(f"Both {npm_install_cmd} and {yarn_install_cmd} failed")
61-
return False
62-
else:
63-
logger.info(f"Successfully executed {yarn_install_cmd}")
56+
logger.error(f"{npm_install_cmd} failed")
57+
return False
6458

6559
# customized json file for obtaining specific items with license-checker
6660
self.make_custom_json(self.tmp_custom_json)
@@ -115,13 +109,15 @@ def parse_transitive_relationship(self):
115109
cmd = 'npm ls -a --omit=dev --json -s'
116110
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, encoding='utf-8')
117111
rel_tree = result.stdout
118-
if rel_tree is None:
119-
logger.error(f"It returns the error: {cmd}")
120-
logger.error(f"No output for {cmd}")
112+
if not rel_tree or rel_tree.strip() == '':
113+
logger.error(f"No output for {cmd}, stderr: {result.stderr}")
114+
ret = False
115+
elif result.returncode > 1:
116+
logger.error(f"'{cmd}' failed with exit code({result.returncode}), stderr: {result.stderr}")
121117
ret = False
122118
if ret:
123119
if result.returncode == 1:
124-
logger.warning(f"'{cmd}' returns error code: {result.stderr}")
120+
logger.debug(f"'{cmd}' has warnings: {result.stderr}")
125121

126122
try:
127123
rel_json = json.loads(rel_tree)
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
# Copyright (c) 2025 LG Electronics Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
import os
7+
import logging
8+
import subprocess
9+
import json
10+
import fosslight_util.constant as constant
11+
import fosslight_dependency.constant as const
12+
from fosslight_dependency.package_manager.Npm import Npm
13+
from fosslight_dependency.dependency_item import DependencyItem, change_dependson_to_purl
14+
from fosslight_util.oss_item import OssItem
15+
from fosslight_dependency._package_manager import get_url_to_purl
16+
from fosslight_dependency.package_manager.Npm import check_multi_license, check_unknown_license
17+
18+
logger = logging.getLogger(constant.LOGGER_NAME)
19+
20+
21+
class Yarn(Npm):
22+
23+
def __init__(self, input_dir, output_dir):
24+
super().__init__(input_dir, output_dir)
25+
self.package_manager_name = const.YARN
26+
self.yarn_version = None
27+
28+
def detect_yarn_version(self):
29+
"""Detect Yarn version (1.x = Classic, 2+ = Berry)"""
30+
if self.yarn_version is not None:
31+
return self.yarn_version
32+
33+
try:
34+
result = subprocess.run('yarn -v', shell=True, capture_output=True, text=True, encoding='utf-8')
35+
if result.returncode == 0:
36+
version_str = result.stdout.strip()
37+
major_version = int(version_str.split('.')[0])
38+
self.yarn_version = major_version
39+
logger.info(f"Detected Yarn version: {version_str} (major: {major_version})")
40+
return major_version
41+
except Exception as e:
42+
logger.warning(f"Failed to detect Yarn version: {e}")
43+
return None
44+
45+
def start_license_checker(self):
46+
ret = True
47+
license_checker_cmd = f'license-checker --production --json --out {self.input_file_name}'
48+
custom_path_option = ' --customPath '
49+
node_modules = 'node_modules'
50+
51+
self.detect_yarn_version()
52+
53+
# For Yarn Berry (2+), check if using PnP mode
54+
is_pnp_mode = False
55+
if self.yarn_version and self.yarn_version >= 2:
56+
# Check if .pnp.cjs exists (PnP mode indicator)
57+
if os.path.exists('.pnp.cjs') or os.path.exists('.pnp.js'):
58+
is_pnp_mode = True
59+
logger.info("Detected Yarn Berry with PnP mode")
60+
61+
if not os.path.isdir(node_modules):
62+
logger.info("node_modules directory does not exist.")
63+
self.flag_tmp_node_modules = True
64+
65+
# For PnP mode, try to force node_modules creation
66+
if is_pnp_mode:
67+
logger.info("Attempting to create node_modules for PnP project...")
68+
yarn_install_cmd = 'YARN_NODE_LINKER=node-modules yarn install --production --ignore-scripts'
69+
logger.info(f"Executing: {yarn_install_cmd}")
70+
else:
71+
yarn_install_cmd = 'yarn install --production --ignore-scripts'
72+
logger.info(f"Executing: {yarn_install_cmd}")
73+
74+
cmd_ret = subprocess.call(yarn_install_cmd, shell=True)
75+
if cmd_ret != 0:
76+
logger.error(f"{yarn_install_cmd} failed")
77+
if is_pnp_mode:
78+
logger.error("Yarn Berry PnP mode detected. Consider setting 'nodeLinker: node-modules' in .yarnrc.yml")
79+
return False
80+
else:
81+
logger.info(f"Successfully executed {yarn_install_cmd}")
82+
83+
self.make_custom_json(self.tmp_custom_json)
84+
85+
cmd = license_checker_cmd + custom_path_option + self.tmp_custom_json
86+
cmd_ret = subprocess.call(cmd, shell=True)
87+
if cmd_ret != 0:
88+
logger.error(f"It returns the error: {cmd}")
89+
logger.error("Please check if the license-checker is installed.(sudo npm install -g license-checker)")
90+
ret = False
91+
else:
92+
self.append_input_package_list_file(self.input_file_name)
93+
if os.path.exists(self.tmp_custom_json):
94+
os.remove(self.tmp_custom_json)
95+
96+
return ret
97+
98+
def parse_oss_information(self, f_name):
99+
with open(f_name, 'r', encoding='utf8') as json_file:
100+
json_data = json.load(json_file)
101+
102+
_licenses = 'licenses'
103+
_repository = 'repository'
104+
_private = 'private'
105+
106+
keys = [key for key in json_data]
107+
purl_dict = {}
108+
for i in range(0, len(keys)):
109+
dep_item = DependencyItem()
110+
oss_item = OssItem()
111+
d = json_data.get(keys[i - 1])
112+
oss_init_name = d['name']
113+
oss_item.name = f'{const.NPM}:{oss_init_name}'
114+
115+
if d[_licenses]:
116+
license_name = d[_licenses]
117+
else:
118+
license_name = ''
119+
120+
oss_item.version = d['version']
121+
package_path = d['path']
122+
123+
private_pkg = False
124+
if _private in d:
125+
if d[_private]:
126+
private_pkg = True
127+
128+
oss_item.download_location = f"{self.dn_url}{oss_init_name}/v/{oss_item.version}"
129+
dn_loc = f"{self.dn_url}{oss_init_name}"
130+
dep_item.purl = get_url_to_purl(oss_item.download_location, self.package_manager_name)
131+
purl_dict[f'{oss_init_name}({oss_item.version})'] = dep_item.purl
132+
if d[_repository]:
133+
dn_loc = d[_repository]
134+
elif private_pkg:
135+
dn_loc = ''
136+
137+
oss_item.homepage = dn_loc
138+
139+
if private_pkg:
140+
oss_item.download_location = oss_item.homepage
141+
oss_item.comment = 'private'
142+
if self.package_name == f'{oss_init_name}({oss_item.version})':
143+
oss_item.comment = 'root package'
144+
elif self.direct_dep and len(self.relation_tree) > 0:
145+
if f'{oss_init_name}({oss_item.version})' in self.relation_tree[self.package_name]:
146+
oss_item.comment = 'direct'
147+
else:
148+
oss_item.comment = 'transitive'
149+
150+
if f'{oss_init_name}({oss_item.version})' in self.relation_tree:
151+
dep_item.depends_on_raw = self.relation_tree[f'{oss_init_name}({oss_item.version})']
152+
153+
# For Yarn, use 'package.json' instead of yarn.lock for license info
154+
manifest_file_path = os.path.join(package_path, 'package.json')
155+
multi_license, license_comment, multi_flag = check_multi_license(license_name, manifest_file_path)
156+
157+
if multi_flag:
158+
oss_item.comment = license_comment
159+
license_name = multi_license
160+
else:
161+
license_name = license_name.replace(",", "")
162+
license_name = check_unknown_license(license_name, manifest_file_path)
163+
oss_item.license = license_name
164+
165+
dep_item.oss_items.append(oss_item)
166+
self.dep_items.append(dep_item)
167+
168+
if self.direct_dep:
169+
self.dep_items = change_dependson_to_purl(purl_dict, self.dep_items)
170+
return
171+
172+
def parse_rel_dependencies(self, rel_name, rel_ver, rel_dependencies):
173+
"""Override to handle missing packages and packages without version"""
174+
_dependencies = 'dependencies'
175+
_version = 'version'
176+
_peer = 'peerMissing'
177+
_missing = 'missing'
178+
179+
for rel_dep_name in rel_dependencies.keys():
180+
# Optional, non-installed dependencies are listed as empty objects
181+
if rel_dependencies[rel_dep_name] == {}:
182+
continue
183+
if _peer in rel_dependencies[rel_dep_name]:
184+
if rel_dependencies[rel_dep_name][_peer]:
185+
continue
186+
# Skip missing packages (not installed)
187+
if _missing in rel_dependencies[rel_dep_name]:
188+
if rel_dependencies[rel_dep_name][_missing]:
189+
continue
190+
# Skip if version key doesn't exist
191+
if _version not in rel_dependencies[rel_dep_name]:
192+
continue
193+
194+
if f'{rel_name}({rel_ver})' not in self.relation_tree:
195+
self.relation_tree[f'{rel_name}({rel_ver})'] = []
196+
elif f'{rel_dep_name}({rel_dependencies[rel_dep_name][_version]})' in self.relation_tree[f'{rel_name}({rel_ver})']:
197+
continue
198+
self.relation_tree[f'{rel_name}({rel_ver})'].append(f'{rel_dep_name}({rel_dependencies[rel_dep_name][_version]})')
199+
if _dependencies in rel_dependencies[rel_dep_name]:
200+
self.parse_rel_dependencies(rel_dep_name, rel_dependencies[rel_dep_name][_version],
201+
rel_dependencies[rel_dep_name][_dependencies])
202+
203+
def parse_direct_dependencies(self):
204+
if not self.direct_dep:
205+
return
206+
try:
207+
# For Yarn, check if package.json exists (not yarn.lock)
208+
# input_package_list_file[0] is the license-checker output file path
209+
manifest_dir = os.path.dirname(self.input_package_list_file[0])
210+
package_json_path = os.path.join(manifest_dir, 'package.json')
211+
212+
if os.path.isfile(package_json_path):
213+
ret, err_msg = self.parse_transitive_relationship()
214+
if not ret:
215+
self.direct_dep = False
216+
logger.warning(f'It cannot print direct/transitive dependency: {err_msg}')
217+
else:
218+
logger.info('Direct/transitive support is not possible because the package.json file does not exist.')
219+
self.direct_dep = False
220+
except Exception as e:
221+
logger.warning(f'Cannot print direct/transitive dependency: {e}')
222+
self.direct_dep = False

0 commit comments

Comments
 (0)