Skip to content

Commit 913af68

Browse files
committed
Support yarn dependency tree
Signed-off-by: 석지영/책임연구원/SW공학(연)Open Source TP <[email protected]>
1 parent aff93ae commit 913af68

File tree

6 files changed

+269
-20
lines changed

6 files changed

+269
-20
lines changed

src/fosslight_dependency/_analyze_dependency.py

Lines changed: 4 additions & 1 deletion
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
@@ -35,8 +36,10 @@ def analyze_dependency(package_manager_name, input_dir, output_dir, pip_activate
3536

3637
if package_manager_name == const.PYPI:
3738
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:
39+
elif package_manager_name == const.NPM:
3940
package_manager = Npm(input_dir, output_dir)
41+
elif package_manager_name == const.YARN:
42+
package_manager = Yarn(input_dir, output_dir)
4043
elif package_manager_name == const.MAVEN:
4144
package_manager = Maven(input_dir, output_dir, output_custom_dir)
4245
elif package_manager_name == const.GRADLE:

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: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,15 @@ def parse_transitive_relationship(self):
115115
cmd = 'npm ls -a --omit=dev --json -s'
116116
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, encoding='utf-8')
117117
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}")
118+
if not rel_tree or rel_tree.strip() == '':
119+
logger.error(f"No output for {cmd}, stderr: {result.stderr}")
120+
ret = False
121+
elif result.returncode > 1:
122+
logger.error(f"'{cmd}' failed with exit code({result.returncode}), stderr: {result.stderr}")
121123
ret = False
122124
if ret:
123125
if result.returncode == 1:
124-
logger.warning(f"'{cmd}' returns error code: {result.stderr}")
126+
logger.debug(f"'{cmd}' has warnings: {result.stderr}")
125127

126128
try:
127129
rel_json = json.loads(rel_tree)
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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+
# Detect Yarn version
52+
self.detect_yarn_version()
53+
54+
# For Yarn Berry (2+), check if using PnP mode
55+
is_pnp_mode = False
56+
if self.yarn_version and self.yarn_version >= 2:
57+
# Check if .pnp.cjs exists (PnP mode indicator)
58+
if os.path.exists('.pnp.cjs') or os.path.exists('.pnp.js'):
59+
is_pnp_mode = True
60+
logger.info("Detected Yarn Berry with PnP mode")
61+
62+
if not os.path.isdir(node_modules):
63+
logger.info(f"node_modules directory does not exist.")
64+
self.flag_tmp_node_modules = True
65+
66+
# For PnP mode, try to force node_modules creation
67+
if is_pnp_mode:
68+
logger.info("Attempting to create node_modules for PnP project...")
69+
# Try setting nodeLinker to node-modules temporarily
70+
yarn_install_cmd = 'YARN_NODE_LINKER=node-modules yarn install --production --ignore-scripts'
71+
logger.info(f"Executing: {yarn_install_cmd}")
72+
else:
73+
yarn_install_cmd = 'yarn install --production --ignore-scripts'
74+
logger.info(f"Executing: {yarn_install_cmd}")
75+
76+
cmd_ret = subprocess.call(yarn_install_cmd, shell=True)
77+
if cmd_ret != 0:
78+
logger.error(f"{yarn_install_cmd} failed")
79+
if is_pnp_mode:
80+
logger.error("Yarn Berry PnP mode detected. Consider setting 'nodeLinker: node-modules' in .yarnrc.yml")
81+
return False
82+
else:
83+
logger.info(f"Successfully executed {yarn_install_cmd}")
84+
85+
self.make_custom_json(self.tmp_custom_json)
86+
87+
cmd = license_checker_cmd + custom_path_option + self.tmp_custom_json
88+
cmd_ret = subprocess.call(cmd, shell=True)
89+
if cmd_ret != 0:
90+
logger.error(f"It returns the error: {cmd}")
91+
logger.error("Please check if the license-checker is installed.(sudo npm install -g license-checker)")
92+
ret = False
93+
else:
94+
self.append_input_package_list_file(self.input_file_name)
95+
if os.path.exists(self.tmp_custom_json):
96+
os.remove(self.tmp_custom_json)
97+
98+
return ret
99+
100+
def parse_oss_information(self, f_name):
101+
with open(f_name, 'r', encoding='utf8') as json_file:
102+
json_data = json.load(json_file)
103+
104+
_licenses = 'licenses'
105+
_repository = 'repository'
106+
_private = 'private'
107+
108+
keys = [key for key in json_data]
109+
purl_dict = {}
110+
for i in range(0, len(keys)):
111+
dep_item = DependencyItem()
112+
oss_item = OssItem()
113+
d = json_data.get(keys[i - 1])
114+
oss_init_name = d['name']
115+
oss_item.name = f'{const.NPM}:{oss_init_name}'
116+
117+
if d[_licenses]:
118+
license_name = d[_licenses]
119+
else:
120+
license_name = ''
121+
122+
oss_item.version = d['version']
123+
package_path = d['path']
124+
125+
private_pkg = False
126+
if _private in d:
127+
if d[_private]:
128+
private_pkg = True
129+
130+
oss_item.download_location = f"{self.dn_url}{oss_init_name}/v/{oss_item.version}"
131+
dn_loc = f"{self.dn_url}{oss_init_name}"
132+
dep_item.purl = get_url_to_purl(oss_item.download_location, self.package_manager_name)
133+
purl_dict[f'{oss_init_name}({oss_item.version})'] = dep_item.purl
134+
if d[_repository]:
135+
dn_loc = d[_repository]
136+
elif private_pkg:
137+
dn_loc = ''
138+
139+
oss_item.homepage = dn_loc
140+
141+
if private_pkg:
142+
oss_item.download_location = oss_item.homepage
143+
oss_item.comment = 'private'
144+
if self.package_name == f'{oss_init_name}({oss_item.version})':
145+
oss_item.comment = 'root package'
146+
elif self.direct_dep and len(self.relation_tree) > 0:
147+
if f'{oss_init_name}({oss_item.version})' in self.relation_tree[self.package_name]:
148+
oss_item.comment = 'direct'
149+
else:
150+
oss_item.comment = 'transitive'
151+
152+
if f'{oss_init_name}({oss_item.version})' in self.relation_tree:
153+
dep_item.depends_on_raw = self.relation_tree[f'{oss_init_name}({oss_item.version})']
154+
155+
# For Yarn, use 'package.json' instead of yarn.lock for license info
156+
manifest_file_path = os.path.join(package_path, 'package.json')
157+
multi_license, license_comment, multi_flag = check_multi_license(license_name, manifest_file_path)
158+
159+
if multi_flag:
160+
oss_item.comment = license_comment
161+
license_name = multi_license
162+
else:
163+
license_name = license_name.replace(",", "")
164+
license_name = check_unknown_license(license_name, manifest_file_path)
165+
oss_item.license = license_name
166+
167+
dep_item.oss_items.append(oss_item)
168+
self.dep_items.append(dep_item)
169+
170+
if self.direct_dep:
171+
self.dep_items = change_dependson_to_purl(purl_dict, self.dep_items)
172+
return
173+
174+
def parse_rel_dependencies(self, rel_name, rel_ver, rel_dependencies):
175+
"""Override to handle missing packages and packages without version"""
176+
_dependencies = 'dependencies'
177+
_version = 'version'
178+
_peer = 'peerMissing'
179+
_missing = 'missing'
180+
181+
for rel_dep_name in rel_dependencies.keys():
182+
# Optional, non-installed dependencies are listed as empty objects
183+
if rel_dependencies[rel_dep_name] == {}:
184+
continue
185+
if _peer in rel_dependencies[rel_dep_name]:
186+
if rel_dependencies[rel_dep_name][_peer]:
187+
continue
188+
# Skip missing packages (not installed)
189+
if _missing in rel_dependencies[rel_dep_name]:
190+
if rel_dependencies[rel_dep_name][_missing]:
191+
continue
192+
# Skip if version key doesn't exist
193+
if _version not in rel_dependencies[rel_dep_name]:
194+
continue
195+
196+
if f'{rel_name}({rel_ver})' not in self.relation_tree:
197+
self.relation_tree[f'{rel_name}({rel_ver})'] = []
198+
elif f'{rel_dep_name}({rel_dependencies[rel_dep_name][_version]})' in self.relation_tree[f'{rel_name}({rel_ver})']:
199+
continue
200+
self.relation_tree[f'{rel_name}({rel_ver})'].append(f'{rel_dep_name}({rel_dependencies[rel_dep_name][_version]})')
201+
if _dependencies in rel_dependencies[rel_dep_name]:
202+
self.parse_rel_dependencies(rel_dep_name, rel_dependencies[rel_dep_name][_version],
203+
rel_dependencies[rel_dep_name][_dependencies])
204+
205+
def parse_direct_dependencies(self):
206+
if not self.direct_dep:
207+
return
208+
try:
209+
# For Yarn, check if package.json exists (not yarn.lock)
210+
# input_package_list_file[0] is the license-checker output file path
211+
manifest_dir = os.path.dirname(self.input_package_list_file[0])
212+
package_json_path = os.path.join(manifest_dir, 'package.json')
213+
214+
if os.path.isfile(package_json_path):
215+
ret, err_msg = self.parse_transitive_relationship()
216+
if not ret:
217+
self.direct_dep = False
218+
logger.warning(f'It cannot print direct/transitive dependency: {err_msg}')
219+
else:
220+
logger.info('Direct/transitive support is not possible because the package.json file does not exist.')
221+
self.direct_dep = False
222+
except Exception as e:
223+
logger.warning(f'Cannot print direct/transitive dependency: {e}')
224+
self.direct_dep = False

src/fosslight_dependency/run_dependency_scanner.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,22 @@ def find_package_manager(input_dir, abs_path_to_exclude=[], manifest_file_name=[
106106
# both npm and pnpm are detected, remove npm.
107107
if 'npm' in found_package_manager.keys() and 'pnpm' in found_package_manager.keys():
108108
del found_package_manager['npm']
109+
110+
# both npm and yarn are detected, check which one to use based on lock file
111+
if 'npm' in found_package_manager.keys() and 'yarn' in found_package_manager.keys():
112+
# Remove npm from directories where yarn.lock exists
113+
dirs_to_remove_from_npm = []
114+
for yarn_dir in found_package_manager['yarn'].keys():
115+
if yarn_dir in found_package_manager['npm']:
116+
dirs_to_remove_from_npm.append(yarn_dir)
117+
118+
for dir_to_remove in dirs_to_remove_from_npm:
119+
del found_package_manager['npm'][dir_to_remove]
120+
121+
# If npm has no directories left, remove it entirely
122+
if not found_package_manager['npm']:
123+
del found_package_manager['npm']
124+
109125
if len(found_package_manager) >= 1:
110126
log_lines = ["\nDetected Manifest Files automatically"]
111127
log_lines = print_package_info(found_package_manager, log_lines)
@@ -208,8 +224,6 @@ def run_dependency_scanner(package_manager='', input_dir='', output_dir_file='',
208224
found_package_manager = {}
209225
if package_manager:
210226
scan_item.set_cover_comment(f"Manual detect mode (-m {package_manager})")
211-
if package_manager == const.YARN:
212-
package_manager = const.NPM
213227
autodetect = False
214228
support_packagemanager = list(const.SUPPORT_PACKAE.keys())
215229

0 commit comments

Comments
 (0)