licenseguard-cli 2.0.0 → 2.1.1

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.
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Python License Scanner - Native Python implementation
4
+ Scans installed packages and extracts license information using importlib.metadata
5
+
6
+ This script is called by Node.js and returns JSON output.
7
+ Handles all worst-case scenarios:
8
+ 1. Python version compatibility (3.7+)
9
+ 2. Read-only filesystems (runs via python -c)
10
+ 3. Encoding issues (force UTF-8)
11
+ 4. Virtualenv detection
12
+ """
13
+
14
+ import sys
15
+ import json
16
+ import os
17
+
18
+ # MITIGATION #3: Force UTF-8 encoding (Windows CP1252 fix)
19
+ if sys.version_info >= (3, 7):
20
+ sys.stdout.reconfigure(encoding='utf-8')
21
+
22
+ # MITIGATION #1: Python version compatibility
23
+ # Try importlib.metadata (Python 3.8+), fallback to pkg_resources (Python 3.7)
24
+ try:
25
+ import importlib.metadata as metadata
26
+ USING_IMPORTLIB = True
27
+ except ImportError:
28
+ try:
29
+ import pkg_resources
30
+ USING_IMPORTLIB = False
31
+ except ImportError:
32
+ print(json.dumps({"error": "Neither importlib.metadata nor pkg_resources available"}), file=sys.stderr)
33
+ sys.exit(1)
34
+
35
+
36
+ def extract_license_from_classifier(classifier):
37
+ """
38
+ Extract license name from Trove Classifier
39
+ Example: "License :: OSI Approved :: MIT License" -> "MIT License"
40
+ """
41
+ parts = classifier.split(' :: ')
42
+ last = parts[-1].strip()
43
+ return last if last != 'OSI Approved' else None
44
+
45
+
46
+ def get_license_via_importlib(package_name):
47
+ """
48
+ Get license using importlib.metadata (Python 3.8+)
49
+ Implements pip-licenses priority: License-Expression > Classifier > License
50
+ """
51
+ try:
52
+ dist = metadata.distribution(package_name)
53
+
54
+ # Priority 1: License-Expression (PEP 639 - modern packages)
55
+ license_expr = dist.metadata.get('License-Expression')
56
+ if license_expr and license_expr.strip() and license_expr.strip().lower() != 'none':
57
+ return license_expr.strip()
58
+
59
+ # Priority 2: Trove Classifiers
60
+ classifiers = dist.metadata.get_all('Classifier') or []
61
+ license_classifiers = [c for c in classifiers if c.startswith('License ::')]
62
+ if license_classifiers:
63
+ license_from_classifier = extract_license_from_classifier(license_classifiers[0])
64
+ if license_from_classifier:
65
+ return license_from_classifier
66
+
67
+ # Priority 3: License field (legacy)
68
+ license_field = dist.metadata.get('License')
69
+ if license_field and license_field.strip() and license_field.strip().lower() != 'none':
70
+ return license_field.strip()
71
+
72
+ return 'UNKNOWN'
73
+
74
+ except Exception:
75
+ return 'UNKNOWN'
76
+
77
+
78
+ def get_license_via_pkg_resources(package_name):
79
+ """
80
+ Get license using pkg_resources (Python 3.7 fallback)
81
+ """
82
+ try:
83
+ dist = pkg_resources.get_distribution(package_name)
84
+
85
+ if dist.has_metadata('METADATA'):
86
+ metadata_content = dist.get_metadata('METADATA')
87
+ lines = metadata_content.split('\n')
88
+
89
+ license_expr = None
90
+ license_field = None
91
+ classifiers = []
92
+
93
+ for line in lines:
94
+ if line.startswith('License-Expression:'):
95
+ license_expr = line.split(':', 1)[1].strip()
96
+ elif line.startswith('License:'):
97
+ license_field = line.split(':', 1)[1].strip()
98
+ elif line.startswith('Classifier:') and 'License ::' in line:
99
+ classifiers.append(line.split(':', 1)[1].strip())
100
+
101
+ # Priority: License-Expression > Classifier > License
102
+ if license_expr and license_expr.lower() != 'none':
103
+ return license_expr
104
+
105
+ if classifiers:
106
+ license_from_classifier = extract_license_from_classifier(classifiers[0])
107
+ if license_from_classifier:
108
+ return license_from_classifier
109
+
110
+ if license_field and license_field.lower() != 'none':
111
+ return license_field
112
+
113
+ return 'UNKNOWN'
114
+
115
+ except Exception:
116
+ return 'UNKNOWN'
117
+
118
+
119
+ def scan_packages(package_names):
120
+ """
121
+ Scan licenses for a list of packages
122
+ Returns: dict {package_name: license_string}
123
+ """
124
+ results = {}
125
+ get_license = get_license_via_importlib if USING_IMPORTLIB else get_license_via_pkg_resources
126
+
127
+ for pkg in package_names:
128
+ # Try exact name first
129
+ license_info = get_license(pkg)
130
+
131
+ # If UNKNOWN, try with normalized name (dashes to underscores)
132
+ if license_info == 'UNKNOWN' and '-' in pkg:
133
+ normalized = pkg.replace('-', '_')
134
+ license_info = get_license(normalized)
135
+
136
+ # If still UNKNOWN, try with underscores to dashes
137
+ if license_info == 'UNKNOWN' and '_' in pkg:
138
+ normalized = pkg.replace('_', '-')
139
+ license_info = get_license(normalized)
140
+
141
+ results[pkg.lower()] = license_info
142
+
143
+ return results
144
+
145
+
146
+ def main():
147
+ """
148
+ Main entry point
149
+ Expects: JSON array of package names via stdin
150
+ Returns: JSON object {package: license} via stdout
151
+ """
152
+ try:
153
+ # Read package list from stdin
154
+ input_data = sys.stdin.read()
155
+ package_names = json.loads(input_data)
156
+
157
+ if not isinstance(package_names, list):
158
+ raise ValueError("Expected JSON array of package names")
159
+
160
+ # Scan licenses
161
+ results = scan_packages(package_names)
162
+
163
+ # Output JSON to stdout
164
+ print(json.dumps(results, ensure_ascii=False))
165
+
166
+ except Exception as e:
167
+ error_output = {"error": str(e)}
168
+ print(json.dumps(error_output), file=sys.stderr)
169
+ sys.exit(1)
170
+
171
+
172
+ if __name__ == '__main__':
173
+ main()
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Python Ecosystem Plugin (pip/pipenv/poetry)
3
+ * Scans Python dependencies for license information
4
+ * Supports three package managers with priority: poetry > pipenv > pip
5
+ */
6
+
7
+ const fs = require('fs')
8
+ const { execSync } = require('child_process')
9
+ const { checkCompatibility } = require('../compat-checker')
10
+ const { showProgress } = require('../progress')
11
+ const { normalize } = require('../license-normalizer')
12
+
13
+ /**
14
+ * Detect if this is a Python project and return package manager
15
+ * Priority order: poetry > pipenv > pip
16
+ * @returns {string|boolean} 'poetry' | 'pipenv' | 'pip' | false
17
+ */
18
+ function detect() {
19
+ const hasPoetry = fs.existsSync('pyproject.toml')
20
+ const hasPipenv = fs.existsSync('Pipfile')
21
+ const hasPip = fs.existsSync('requirements.txt')
22
+
23
+ // Priority: poetry > pipenv > pip
24
+ if (hasPoetry) return 'poetry'
25
+ if (hasPipenv) return 'pipenv'
26
+ if (hasPip) return 'pip'
27
+
28
+ return false
29
+ }
30
+
31
+ /**
32
+ * Parse requirements.txt to get dependency list
33
+ * @returns {string[]} Array of package names
34
+ */
35
+ function parseRequirementsTxt() {
36
+ if (!fs.existsSync('requirements.txt')) {
37
+ return []
38
+ }
39
+
40
+ const content = fs.readFileSync('requirements.txt', 'utf8')
41
+ const packages = []
42
+
43
+ for (const line of content.split('\n')) {
44
+ const trimmed = line.trim()
45
+
46
+ // Skip empty lines and comments
47
+ if (!trimmed || trimmed.startsWith('#')) {
48
+ continue
49
+ }
50
+
51
+ // Extract package name from various formats:
52
+ // requests==2.31.0
53
+ // numpy>=1.24.0
54
+ // pandas~=2.0.0
55
+ // flask[async]>=2.0.0
56
+ // git+https://... (skip)
57
+ // -r requirements-dev.txt (skip)
58
+ // -e . (skip editable installs)
59
+
60
+ if (trimmed.startsWith('-') || trimmed.startsWith('git+') || trimmed.startsWith('http')) {
61
+ continue
62
+ }
63
+
64
+ // Extract package name before version specifier or extras
65
+ const match = trimmed.match(/^([a-zA-Z0-9_-]+)/)
66
+ if (match) {
67
+ packages.push(match[1])
68
+ }
69
+ }
70
+
71
+ return packages
72
+ }
73
+
74
+ /**
75
+ * Parse Pipfile to get dependency list
76
+ * @returns {string[]} Array of package names
77
+ */
78
+ function parsePipfile() {
79
+ if (!fs.existsSync('Pipfile')) {
80
+ return []
81
+ }
82
+
83
+ const content = fs.readFileSync('Pipfile', 'utf8')
84
+ const packages = []
85
+ let inPackages = false
86
+
87
+ for (const line of content.split('\n')) {
88
+ const trimmed = line.trim()
89
+
90
+ // Check for [packages] section
91
+ if (trimmed === '[packages]') {
92
+ inPackages = true
93
+ continue
94
+ }
95
+
96
+ // Check for other sections (end of packages)
97
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
98
+ inPackages = false
99
+ continue
100
+ }
101
+
102
+ // Parse package in packages section
103
+ // Format: package = "version" or package = {version = "1.0.0"}
104
+ if (inPackages && trimmed && !trimmed.startsWith('#')) {
105
+ const match = trimmed.match(/^([a-zA-Z0-9_-]+)\s*=/)
106
+ if (match) {
107
+ packages.push(match[1])
108
+ }
109
+ }
110
+ }
111
+
112
+ return packages
113
+ }
114
+
115
+ /**
116
+ * Parse pyproject.toml to get dependency list (Poetry format)
117
+ * @returns {string[]} Array of package names
118
+ */
119
+ function parsePyprojectToml() {
120
+ if (!fs.existsSync('pyproject.toml')) {
121
+ return []
122
+ }
123
+
124
+ const content = fs.readFileSync('pyproject.toml', 'utf8')
125
+ const packages = []
126
+ let inDependencies = false
127
+
128
+ for (const line of content.split('\n')) {
129
+ const trimmed = line.trim()
130
+
131
+ // Check for [tool.poetry.dependencies] section
132
+ if (trimmed === '[tool.poetry.dependencies]') {
133
+ inDependencies = true
134
+ continue
135
+ }
136
+
137
+ // Check for other sections (end of dependencies)
138
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
139
+ inDependencies = false
140
+ continue
141
+ }
142
+
143
+ // Parse package in dependencies section
144
+ // Format: package = "^1.0.0" or package = {version = "^1.0.0"}
145
+ if (inDependencies && trimmed && !trimmed.startsWith('#')) {
146
+ // Skip python version specification
147
+ if (trimmed.startsWith('python =')) {
148
+ continue
149
+ }
150
+
151
+ const match = trimmed.match(/^([a-zA-Z0-9_-]+)\s*=/)
152
+ if (match) {
153
+ packages.push(match[1])
154
+ }
155
+ }
156
+ }
157
+
158
+ return packages
159
+ }
160
+
161
+ /**
162
+ * Batch fetch licenses using native Python script
163
+ * Uses importlib.metadata directly in Python for maximum reliability
164
+ * Handles: version compatibility, encoding, virtualenv detection
165
+ *
166
+ * @param {string[]} packageNames - Array of package names
167
+ * @param {string} manager - Package manager (for error messages)
168
+ * @returns {Object} Map of { packageName: licenseString }
169
+ */
170
+ function getLicensesBatch(packageNames, _manager) {
171
+ if (packageNames.length === 0) return {}
172
+
173
+ const CHUNK_SIZE = 100 // Process in chunks to avoid stdin size limits
174
+ const licenseMap = {}
175
+ const totalChunks = Math.ceil(packageNames.length / CHUNK_SIZE)
176
+
177
+ // Path to Python scanner script
178
+ const scriptPath = `${__dirname}/python-license-scanner.py`
179
+
180
+ for (let i = 0; i < packageNames.length; i += CHUNK_SIZE) {
181
+ const currentChunk = Math.floor(i / CHUNK_SIZE) + 1
182
+ showProgress(currentChunk, totalChunks)
183
+
184
+ const chunk = packageNames.slice(i, i + CHUNK_SIZE)
185
+
186
+ try {
187
+ // Call Python scanner script with package list via stdin
188
+ const input = JSON.stringify(chunk)
189
+ const output = execSync(`python "${scriptPath}"`, {
190
+ input: input,
191
+ encoding: 'utf8',
192
+ timeout: 60000, // 60 seconds for large chunks
193
+ stdio: ['pipe', 'pipe', 'pipe']
194
+ })
195
+
196
+ // Parse JSON response
197
+ const results = JSON.parse(output)
198
+
199
+ // Normalize licenses before adding to map (using shared normalizer)
200
+ for (const [pkg, license] of Object.entries(results)) {
201
+ const normalized = normalize(license)
202
+ licenseMap[pkg.toLowerCase()] = normalized
203
+ }
204
+
205
+ } catch (error) {
206
+ const stderr = error.stderr || ''
207
+ const message = error.message || ''
208
+
209
+ // 1. FATAL: Python not available
210
+ if (message.includes('command not found') ||
211
+ message.includes('not recognized') ||
212
+ message.includes('ENOENT')) {
213
+ throw new Error(
214
+ 'Python not installed.\n\n' +
215
+ 'Install Python 3.7+ from: https://www.python.org/downloads/\n' +
216
+ 'Or skip scanning: licenseguard init --noscan'
217
+ )
218
+ }
219
+
220
+ // 2. Try to parse error output for diagnostics
221
+ try {
222
+ const errorData = JSON.parse(stderr)
223
+ if (errorData.error) {
224
+ throw new Error(`Python scanner error: ${errorData.error}`)
225
+ }
226
+ } catch (parseErr) {
227
+ // stderr is not JSON, show raw error
228
+ }
229
+
230
+ // 3. FATAL: System errors
231
+ const fatalErrors = [
232
+ 'Permission denied',
233
+ 'No space left on device',
234
+ 'Disk quota exceeded',
235
+ 'Read-only file system'
236
+ ]
237
+
238
+ const isFatal = fatalErrors.some(err =>
239
+ stderr.includes(err) || message.includes(err)
240
+ )
241
+
242
+ if (isFatal) {
243
+ throw new Error(
244
+ 'Python scanner failed with system error:\n\n' +
245
+ (stderr || message)
246
+ )
247
+ }
248
+
249
+ // 4. UNKNOWN: Mark all packages in this chunk as UNKNOWN
250
+ for (const pkg of chunk) {
251
+ licenseMap[pkg.toLowerCase()] = 'UNKNOWN'
252
+ }
253
+ }
254
+ }
255
+
256
+ return licenseMap
257
+ }
258
+
259
+ /**
260
+ * Scan all Python dependencies for license compatibility
261
+ * Uses batch processing for 30x performance improvement on large projects
262
+ * @param {string} projectLicense - The project's license (SPDX format)
263
+ * @returns {Promise<Object>} Scan results
264
+ */
265
+ async function scanDependencies(projectLicense) {
266
+ const manager = detect()
267
+
268
+ if (!manager) {
269
+ throw new Error('No Python package manager detected (requirements.txt, Pipfile, or pyproject.toml)')
270
+ }
271
+
272
+ // Get dependency list based on manager
273
+ let packages = []
274
+ if (manager === 'poetry') {
275
+ packages = parsePyprojectToml()
276
+ } else if (manager === 'pipenv') {
277
+ packages = parsePipfile()
278
+ } else {
279
+ packages = parseRequirementsTxt()
280
+ }
281
+
282
+ // Normalize package names to lowercase for consistent lookup
283
+ packages = packages.map(pkg => pkg.toLowerCase())
284
+
285
+ // Batch fetch licenses (O(n/50) instead of O(n) execSync calls)
286
+ const licenseMap = getLicensesBatch(packages, manager)
287
+
288
+ const results = {
289
+ timestamp: new Date().toISOString(),
290
+ totalDependencies: packages.length,
291
+ compatible: 0,
292
+ incompatible: 0,
293
+ unknown: 0,
294
+ issues: []
295
+ }
296
+
297
+ // Process results (fast in-memory loop)
298
+ for (const packageName of packages) {
299
+ const license = licenseMap[packageName] || 'UNKNOWN'
300
+
301
+ // Check compatibility using universal compat-checker
302
+ const compatResult = checkCompatibility(projectLicense, license)
303
+
304
+ if (!compatResult.compatible) {
305
+ const isUnknown = license === 'UNKNOWN'
306
+
307
+ if (isUnknown) {
308
+ results.unknown++
309
+ } else {
310
+ results.incompatible++
311
+ }
312
+
313
+ results.issues.push({
314
+ package: packageName,
315
+ license: license,
316
+ type: isUnknown ? 'warning' : 'conflict',
317
+ reason: compatResult.reason,
318
+ location: 'Python site-packages'
319
+ })
320
+ } else {
321
+ results.compatible++
322
+ }
323
+ }
324
+
325
+ return results
326
+ }
327
+
328
+ module.exports = {
329
+ detect,
330
+ scanDependencies,
331
+ // Export internal functions for testing
332
+ parseRequirementsTxt,
333
+ parsePipfile,
334
+ parsePyprojectToml,
335
+ getLicensesBatch
336
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Rust Ecosystem Plugin (Cargo)
3
+ * Scans Cargo dependencies for license information
4
+ */
5
+
6
+ const fs = require('fs')
7
+ const { execSync } = require('child_process')
8
+ const { checkCompatibility } = require('../compat-checker')
9
+ const { showProgress } = require('../progress')
10
+
11
+ /**
12
+ * Detect if this is a Rust Cargo project
13
+ * @returns {boolean} True if Cargo.toml exists
14
+ */
15
+ function detect() {
16
+ return fs.existsSync('Cargo.toml')
17
+ }
18
+
19
+ /**
20
+ * Get Cargo metadata via CLI
21
+ * @returns {Object} Cargo metadata JSON
22
+ */
23
+ function getCargoMetadata() {
24
+ try {
25
+ const output = execSync('cargo metadata --format-version 1', {
26
+ encoding: 'utf8',
27
+ timeout: 30000, // 30 second timeout for large projects
28
+ cwd: process.cwd(),
29
+ stdio: ['pipe', 'pipe', 'pipe']
30
+ })
31
+ return JSON.parse(output)
32
+ } catch (error) {
33
+ // Check if Cargo is not installed
34
+ if (error.message.includes('command not found') ||
35
+ error.message.includes('not recognized') ||
36
+ error.message.includes('ENOENT')) {
37
+ throw new Error(
38
+ 'Cargo not installed.\n\n' +
39
+ 'Install Rust/Cargo: https://rustup.rs\n' +
40
+ 'Or skip scanning: licenseguard init --noscan'
41
+ )
42
+ }
43
+
44
+ // Check for project not found / no Cargo.toml
45
+ if (error.message.includes('could not find') ||
46
+ error.message.includes('no Cargo.toml')) {
47
+ throw new Error(
48
+ 'No Cargo.toml found in current directory.\n\n' +
49
+ 'Make sure you are in a Rust project directory.'
50
+ )
51
+ }
52
+
53
+ throw new Error('Failed to get Cargo metadata: ' + error.message)
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Normalize license string from Rust ecosystem conventions to SPDX format
59
+ * Many older Rust crates use "/" instead of " OR " for dual-licensing
60
+ * e.g., "MIT/Apache-2.0" → "MIT OR Apache-2.0"
61
+ *
62
+ * @param {string} license - Raw license string from Cargo metadata
63
+ * @returns {string} Normalized SPDX-compliant license string
64
+ */
65
+ function normalizeLicense(license) {
66
+ if (!license) return license
67
+
68
+ // Rust ecosystem convention: "/" means OR (dual-licensing)
69
+ // Convert "MIT/Apache-2.0" → "MIT OR Apache-2.0"
70
+ // Handle both "MIT/Apache-2.0" and "Apache-2.0 / MIT" (with spaces)
71
+ if (license.includes('/') && !license.includes(' OR ')) {
72
+ return license
73
+ .split('/')
74
+ .map(part => part.trim())
75
+ .join(' OR ')
76
+ }
77
+
78
+ return license
79
+ }
80
+
81
+ /**
82
+ * Extract license from Cargo package metadata
83
+ * @param {Object} pkg - Package object from cargo metadata
84
+ * @returns {string} License identifier or 'UNKNOWN'
85
+ */
86
+ function extractCrateLicense(pkg) {
87
+ // Cargo metadata includes license field (SPDX format)
88
+ // e.g., "MIT", "Apache-2.0", "MIT OR Apache-2.0"
89
+ if (pkg.license) {
90
+ // Normalize legacy Rust license format to SPDX
91
+ return normalizeLicense(pkg.license)
92
+ }
93
+
94
+ // Some crates use license_file instead
95
+ if (pkg.license_file) {
96
+ return 'UNKNOWN' // Would need to parse license file content
97
+ }
98
+
99
+ return 'UNKNOWN'
100
+ }
101
+
102
+ /**
103
+ * Filter packages to only include actual dependencies (not root package)
104
+ * @param {Object} metadata - Cargo metadata object
105
+ * @returns {Array} Array of dependency packages
106
+ */
107
+ function filterDependencies(metadata) {
108
+ const packages = metadata.packages || []
109
+ const rootPackageId = metadata.resolve?.root
110
+
111
+ // Filter out the root package (the project itself)
112
+ return packages.filter(pkg => {
113
+ // Skip if this is the root package
114
+ if (rootPackageId && pkg.id === rootPackageId) {
115
+ return false
116
+ }
117
+
118
+ // Skip packages that are path dependencies from the workspace root
119
+ // These are typically the project's own packages
120
+ if (pkg.source === null && pkg.manifest_path) {
121
+ const manifestPath = pkg.manifest_path
122
+ // If manifest is in the current directory or a subdirectory, it's part of the project
123
+ const cwd = process.cwd()
124
+ if (manifestPath.startsWith(cwd)) {
125
+ return false
126
+ }
127
+ }
128
+
129
+ return true
130
+ })
131
+ }
132
+
133
+ /**
134
+ * Scan all Cargo dependencies for license compatibility
135
+ * @param {string} projectLicense - The project's license (SPDX format)
136
+ * @returns {Promise<Object>} Scan results
137
+ */
138
+ async function scanDependencies(projectLicense) {
139
+ // Get metadata via Cargo CLI
140
+ const metadata = getCargoMetadata()
141
+
142
+ // Filter to only actual dependencies
143
+ const packages = filterDependencies(metadata)
144
+
145
+ const results = {
146
+ timestamp: new Date().toISOString(),
147
+ totalDependencies: packages.length,
148
+ compatible: 0,
149
+ incompatible: 0,
150
+ unknown: 0,
151
+ issues: []
152
+ }
153
+
154
+ // Scan each dependency
155
+ for (let i = 0; i < packages.length; i++) {
156
+ showProgress(i + 1, packages.length)
157
+
158
+ const pkg = packages[i]
159
+ const license = extractCrateLicense(pkg)
160
+
161
+ // Check compatibility using universal compat-checker
162
+ const compatResult = checkCompatibility(projectLicense, license)
163
+
164
+ if (!compatResult.compatible) {
165
+ const isUnknown = license === 'UNKNOWN'
166
+
167
+ if (isUnknown) {
168
+ results.unknown++
169
+ } else {
170
+ results.incompatible++
171
+ }
172
+
173
+ results.issues.push({
174
+ package: `${pkg.name}@${pkg.version}`,
175
+ license: license,
176
+ type: isUnknown ? 'warning' : 'conflict',
177
+ reason: compatResult.reason,
178
+ location: pkg.manifest_path || 'crates.io'
179
+ })
180
+ } else {
181
+ results.compatible++
182
+ }
183
+ }
184
+
185
+ return results
186
+ }
187
+
188
+ module.exports = {
189
+ detect,
190
+ scanDependencies,
191
+ // Export internal functions for testing
192
+ getCargoMetadata,
193
+ extractCrateLicense,
194
+ filterDependencies,
195
+ normalizeLicense
196
+ }