licenseguard-cli 1.2.2 → 2.1.0

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,433 @@
1
+ /**
2
+ * License compatibility checker
3
+ * Uses SPDX libraries for standard licenses + custom rules for non-SPDX
4
+ * Enhanced with authoritative compatibility matrix (FSF, Mozilla, Apache sources)
5
+ */
6
+
7
+ const parse = require('spdx-expression-parse')
8
+ const { normalize, areSameLicense } = require('./license-normalizer')
9
+ const COMPAT_MATRIX = require('./license-compatibility-matrix.json')
10
+
11
+ /**
12
+ * Custom compatibility rules for non-SPDX licenses
13
+ * @type {Object}
14
+ */
15
+ const CUSTOM_COMPAT = {
16
+ wtfpl: {
17
+ type: 'permissive',
18
+ compatibleWith: '*', // Compatible with everything
19
+ description: 'Do What The F*ck You Want To Public License'
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Known copyleft license patterns that impose restrictions
25
+ * These require derivative works to use the same license
26
+ */
27
+ const COPYLEFT_PATTERNS = [
28
+ 'GPL', // GNU General Public License (GPL-2.0, GPL-3.0, etc.)
29
+ 'AGPL', // GNU Affero General Public License
30
+ 'LGPL', // GNU Lesser General Public License
31
+ 'MPL', // Mozilla Public License
32
+ 'EPL', // Eclipse Public License
33
+ 'EUPL', // European Union Public License
34
+ 'CDDL', // Common Development and Distribution License
35
+ 'CPL', // Common Public License
36
+ 'APSL', // Apple Public Source License
37
+ 'OSL', // Open Software License
38
+ 'QPL', // Q Public License
39
+ 'RPSL', // RealNetworks Public Source License
40
+ 'SISSL', // Sun Industry Standards Source License
41
+ 'SPL', // Sun Public License
42
+ 'Watcom' // Sybase Open Watcom Public License
43
+ ]
44
+
45
+ /**
46
+ * Permissive license exceptions that might contain misleading patterns
47
+ * These are ultra-permissive and should ALWAYS be allowed
48
+ */
49
+ const PERMISSIVE_EXCEPTIONS = [
50
+ 'WTFPL', // Do What The F*ck You Want To Public License
51
+ 'Unlicense', // Public domain dedication
52
+ 'CC0', // Creative Commons Zero (public domain)
53
+ '0BSD' // BSD Zero Clause (public domain equivalent)
54
+ ]
55
+
56
+ /**
57
+ * Detect if a license is copyleft (requires derivative works to use same license)
58
+ * Uses smart pattern matching instead of hardcoded whitelist
59
+ *
60
+ * @param {string} license - SPDX license identifier
61
+ * @returns {boolean} True if copyleft, false if permissive
62
+ *
63
+ * Algorithm:
64
+ * 1. Check if license is ultra-permissive exception → return false
65
+ * 2. Check if license contains copyleft pattern → return true
66
+ * 3. Default to permissive (safe assumption for ~95% of licenses)
67
+ */
68
+ function isCopyleft(license) {
69
+ if (!license) return false
70
+
71
+ const upper = license.toUpperCase()
72
+
73
+ // Tier 1: Ultra-permissive exceptions (always allow)
74
+ if (PERMISSIVE_EXCEPTIONS.some(p => upper.includes(p.toUpperCase()))) {
75
+ return false
76
+ }
77
+
78
+ // Tier 2: Known copyleft patterns (block these)
79
+ if (COPYLEFT_PATTERNS.some(pattern => upper.includes(pattern))) {
80
+ return true
81
+ }
82
+
83
+ // Tier 3: Default to permissive (safe assumption)
84
+ // Most licenses are permissive (MIT-like, BSD-like, Apache-like)
85
+ return false
86
+ }
87
+
88
+ /**
89
+ * Check license compatibility using authoritative compatibility matrix
90
+ * Handles SPDX normalization, upgrade paths, and explicit compatibility rules
91
+ *
92
+ * @param {string} projectLicense - The project's license (will be normalized)
93
+ * @param {string} depLicense - The dependency's license (will be normalized)
94
+ * @returns {{compatible: boolean, reason: string, severity: string, source: object|null}} Enhanced compatibility result
95
+ *
96
+ * Algorithm:
97
+ * 1. Normalize both licenses to canonical SPDX form
98
+ * 2. Check if same license (compatible)
99
+ * 3. Lookup project license in matrix
100
+ * 4. Check explicit compatibility/incompatibility rules
101
+ * 5. Check upgrade paths (LGPL→GPL, MPL→GPL)
102
+ * 6. Expand wildcards (*permissive*, *copyleft*)
103
+ * 7. Return result with severity and source citations
104
+ */
105
+ function checkWithMatrix(projectLicense, depLicense) {
106
+ // Normalize licenses to canonical SPDX form
107
+ const normalizedProject = normalize(projectLicense)
108
+ const normalizedDep = normalize(depLicense)
109
+
110
+ // Same license is always compatible
111
+ if (areSameLicense(normalizedProject, normalizedDep)) {
112
+ return {
113
+ compatible: true,
114
+ reason: `Same license (${normalizedDep})`,
115
+ severity: 'PASS',
116
+ source: null
117
+ }
118
+ }
119
+
120
+ // Lookup project license in matrix
121
+ const projectEntry = COMPAT_MATRIX.licenses[normalizedProject]
122
+
123
+ if (!projectEntry) {
124
+ // Project license not in matrix - fall back to conservative check
125
+ return {
126
+ compatible: true, // Conservative: allow unknown combinations with warning
127
+ reason: `Unknown project license (${normalizedProject}) - unable to verify compatibility`,
128
+ severity: 'WARNING',
129
+ source: null
130
+ }
131
+ }
132
+
133
+ // Check explicit incompatibility first
134
+ if (projectEntry.incompatible_with) {
135
+ // Direct match
136
+ if (projectEntry.incompatible_with.includes(normalizedDep)) {
137
+ return {
138
+ compatible: false,
139
+ reason: `${normalizedDep} explicitly incompatible with ${normalizedProject}`,
140
+ severity: 'ERROR',
141
+ source: projectEntry.sources
142
+ }
143
+ }
144
+
145
+ // Wildcard match (*copyleft*, *permissive*)
146
+ for (const incompatRule of projectEntry.incompatible_with) {
147
+ if (incompatRule.startsWith('*') && incompatRule.endsWith('*')) {
148
+ const wildcardKey = incompatRule
149
+ if (COMPAT_MATRIX.wildcards[wildcardKey] && COMPAT_MATRIX.wildcards[wildcardKey].includes(normalizedDep)) {
150
+ return {
151
+ compatible: false,
152
+ reason: `${normalizedDep} (${wildcardKey.replace(/\*/g, '')}) incompatible with ${normalizedProject}`,
153
+ severity: 'ERROR',
154
+ source: projectEntry.sources
155
+ }
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ // Check explicit compatibility
162
+ if (projectEntry.compatible_with) {
163
+ // Direct match
164
+ if (projectEntry.compatible_with.includes(normalizedDep)) {
165
+ return {
166
+ compatible: true,
167
+ reason: `${normalizedDep} explicitly compatible with ${normalizedProject}`,
168
+ severity: 'PASS',
169
+ source: projectEntry.sources
170
+ }
171
+ }
172
+
173
+ // Upgrade path (LGPL→GPL, MPL→GPL)
174
+ if (projectEntry.can_upgrade_from && projectEntry.can_upgrade_from.includes(normalizedDep)) {
175
+ return {
176
+ compatible: true,
177
+ reason: `${normalizedDep} can upgrade to ${normalizedProject} (Section 3 upgrade path)`,
178
+ severity: 'PASS',
179
+ source: projectEntry.sources
180
+ }
181
+ }
182
+
183
+ // Wildcard match (*permissive*, *copyleft*, *)
184
+ for (const compatRule of projectEntry.compatible_with) {
185
+ if (compatRule === '*') {
186
+ // Universal compatibility (public domain)
187
+ return {
188
+ compatible: true,
189
+ reason: `${normalizedDep} compatible with ${normalizedProject} (public domain)`,
190
+ severity: 'PASS',
191
+ source: projectEntry.sources
192
+ }
193
+ }
194
+
195
+ if (compatRule.startsWith('*') && compatRule.endsWith('*')) {
196
+ const wildcardKey = compatRule
197
+ if (COMPAT_MATRIX.wildcards[wildcardKey] && COMPAT_MATRIX.wildcards[wildcardKey].includes(normalizedDep)) {
198
+ return {
199
+ compatible: true,
200
+ reason: `${normalizedDep} (${wildcardKey.replace(/\*/g, '')}) compatible with ${normalizedProject}`,
201
+ severity: 'PASS',
202
+ source: projectEntry.sources
203
+ }
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ // No explicit rule found - conservative default
210
+ return {
211
+ compatible: true, // Conservative: allow with warning
212
+ reason: `No explicit compatibility rule for ${normalizedDep} + ${normalizedProject} - verify manually`,
213
+ severity: 'WARNING',
214
+ source: null
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Check compatibility of a single (atomic) license identifier
220
+ * Uses authoritative compatibility matrix for copyleft combinations
221
+ *
222
+ * @param {string} projectLicense - The project's license
223
+ * @param {string} depLicense - Single license identifier (not expression)
224
+ * @returns {{compatible: boolean, reason: string}} Compatibility result
225
+ */
226
+ function checkSingleCompatibility(projectLicense, depLicense) {
227
+ // Normalize licenses first for accurate same-license detection
228
+ const normalizedProject = normalize(projectLicense)
229
+ const normalizedDep = normalize(depLicense)
230
+
231
+ // Same license is always compatible (handles GPL-3.0 vs GPL-3.0-only)
232
+ if (areSameLicense(normalizedProject, normalizedDep)) {
233
+ return { compatible: true, reason: `Same license (${normalizedDep})` }
234
+ }
235
+
236
+ // Smart copyleft detection
237
+ const projectIsCopyleft = isCopyleft(normalizedProject)
238
+ const depIsCopyleft = isCopyleft(normalizedDep)
239
+
240
+ // Case 1: Permissive project + copyleft dependency = INCOMPATIBLE
241
+ if (!projectIsCopyleft && depIsCopyleft) {
242
+ return {
243
+ compatible: false,
244
+ reason: `Copyleft license ${normalizedDep} incompatible with permissive ${normalizedProject}`
245
+ }
246
+ }
247
+
248
+ // Case 2: Copyleft project + permissive dependency
249
+ // IMPORTANT: Still check matrix because some permissive licenses have
250
+ // specific incompatibilities (e.g., Apache-2.0 + GPL-2.0 patent conflict)
251
+ if (projectIsCopyleft && !depIsCopyleft) {
252
+ const matrixResult = checkWithMatrix(normalizedProject, normalizedDep)
253
+ // If matrix says incompatible, trust it (handles Apache-2.0 + GPL-2.0)
254
+ if (!matrixResult.compatible) {
255
+ return {
256
+ compatible: false,
257
+ reason: matrixResult.reason
258
+ }
259
+ }
260
+ // Otherwise, permissive dep is compatible with copyleft project
261
+ return {
262
+ compatible: true,
263
+ reason: 'Permissive dependency compatible with copyleft project'
264
+ }
265
+ }
266
+
267
+ // Case 3: Both permissive = COMPATIBLE
268
+ if (!projectIsCopyleft && !depIsCopyleft) {
269
+ return {
270
+ compatible: true,
271
+ reason: 'Both permissive licenses'
272
+ }
273
+ }
274
+
275
+ // Case 4: Both copyleft - USE MATRIX INSTEAD OF NAIVE LOGIC
276
+ // CRITICAL FIX: This replaces the naive "different copyleft = incompatible" logic
277
+ if (projectIsCopyleft && depIsCopyleft) {
278
+ const matrixResult = checkWithMatrix(normalizedProject, normalizedDep)
279
+ // Return simplified result (remove severity/source for backward compatibility)
280
+ return {
281
+ compatible: matrixResult.compatible,
282
+ reason: matrixResult.reason
283
+ }
284
+ }
285
+
286
+ // Fallback
287
+ return { compatible: true, reason: 'No known incompatibility' }
288
+ }
289
+
290
+ /**
291
+ * Recursively evaluate SPDX expression AST for compatibility
292
+ * Handles OR (disjunctive) and AND (conjunctive) expressions
293
+ *
294
+ * @param {string} projectLicense - The project's license
295
+ * @param {Object} licenseNode - AST node from spdx-expression-parse
296
+ * @returns {{compatible: boolean, reason: string}} Compatibility result
297
+ */
298
+ function isCompatibleRecursive(projectLicense, licenseNode) {
299
+ // Base Case: Leaf node with 'license' property
300
+ if (licenseNode.license) {
301
+ return checkSingleCompatibility(projectLicense, licenseNode.license)
302
+ }
303
+
304
+ // Handle "OR" (Disjunctive) - User can choose either
305
+ if (licenseNode.conjunction === 'or') {
306
+ const left = isCompatibleRecursive(projectLicense, licenseNode.left)
307
+ const right = isCompatibleRecursive(projectLicense, licenseNode.right)
308
+
309
+ // If either is compatible, the whole expression is compatible
310
+ if (left.compatible) return left
311
+ if (right.compatible) return right
312
+
313
+ // Both incompatible - return left's reason
314
+ return { compatible: false, reason: left.reason }
315
+ }
316
+
317
+ // Handle "AND" (Conjunctive) - Must satisfy both
318
+ if (licenseNode.conjunction === 'and') {
319
+ const left = isCompatibleRecursive(projectLicense, licenseNode.left)
320
+ const right = isCompatibleRecursive(projectLicense, licenseNode.right)
321
+
322
+ // Both must be compatible
323
+ if (!left.compatible) {
324
+ return { compatible: false, reason: `Part of AND expression failed: ${left.reason}` }
325
+ }
326
+ if (!right.compatible) {
327
+ return { compatible: false, reason: `Part of AND expression failed: ${right.reason}` }
328
+ }
329
+
330
+ return { compatible: true, reason: 'All licenses in AND expression are compatible' }
331
+ }
332
+
333
+ // Unknown structure
334
+ return { compatible: false, reason: 'Unknown license expression structure' }
335
+ }
336
+
337
+ /**
338
+ * Check if two licenses are compatible
339
+ * Handles complex SPDX expressions with OR/AND
340
+ *
341
+ * @param {string} projectLicense - The project's license
342
+ * @param {string} depLicense - The dependency's license (can be SPDX expression)
343
+ * @returns {{compatible: boolean, reason: string}} Compatibility result
344
+ */
345
+ function checkCompatibility(projectLicense, depLicense) {
346
+ // Handle unknown licenses
347
+ if (depLicense === 'UNKNOWN' || !depLicense) {
348
+ return {
349
+ compatible: false,
350
+ reason: 'No license field found'
351
+ }
352
+ }
353
+
354
+ // Handle custom licenses (WTFPL, etc.)
355
+ const depLowerCase = depLicense.toLowerCase()
356
+ if (CUSTOM_COMPAT[depLowerCase]) {
357
+ if (CUSTOM_COMPAT[depLowerCase].compatibleWith === '*') {
358
+ return { compatible: true, reason: 'Ultra-permissive license' }
359
+ }
360
+ }
361
+
362
+ // Parse SPDX expression into AST
363
+ let ast
364
+ try {
365
+ ast = parse(depLicense)
366
+ } catch (error) {
367
+ return {
368
+ compatible: false,
369
+ reason: `Invalid SPDX expression: ${depLicense}`
370
+ }
371
+ }
372
+
373
+ // Evaluate AST recursively
374
+ return isCompatibleRecursive(projectLicense, ast)
375
+ }
376
+
377
+ /**
378
+ * Format compatibility result with source citations for --explain flag
379
+ * Shows authoritative sources (FSF, Mozilla, Apache) and URLs
380
+ *
381
+ * @param {string} projectLicense - The project's license
382
+ * @param {string} depLicense - The dependency's license
383
+ * @returns {string} Formatted explanation with sources
384
+ *
385
+ * @example
386
+ * explainCompatibility('GPL-3.0', 'LGPL-2.1-or-later')
387
+ * // Returns:
388
+ * // "✅ Compatible: LGPL-2.1-or-later can upgrade to GPL-3.0-only (Section 3 upgrade path)
389
+ * //
390
+ * // Source: LGPL Section 3: Can upgrade to corresponding GPL version
391
+ * // URL: https://www.gnu.org/licenses/lgpl-3.0.html#section3"
392
+ */
393
+ function explainCompatibility(projectLicense, depLicense) {
394
+ const result = checkWithMatrix(projectLicense, depLicense)
395
+
396
+ let explanation = ''
397
+
398
+ // Status emoji
399
+ if (result.compatible && result.severity === 'PASS') {
400
+ explanation += '✅ Compatible: '
401
+ } else if (result.compatible && result.severity === 'WARNING') {
402
+ explanation += '⚠️ Warning: '
403
+ } else {
404
+ explanation += '❌ Incompatible: '
405
+ }
406
+
407
+ // Reason
408
+ explanation += result.reason
409
+
410
+ // Source citations (if available)
411
+ if (result.source && result.source.citation) {
412
+ explanation += '\n\n'
413
+ explanation += `📚 Source: ${result.source.citation}`
414
+ if (result.source.url) {
415
+ explanation += `\n🔗 URL: ${result.source.url}`
416
+ }
417
+ }
418
+
419
+ return explanation
420
+ }
421
+
422
+ module.exports = {
423
+ checkCompatibility,
424
+ checkSingleCompatibility,
425
+ isCompatibleRecursive,
426
+ isCopyleft,
427
+ checkWithMatrix,
428
+ explainCompatibility,
429
+ CUSTOM_COMPAT,
430
+ COPYLEFT_PATTERNS,
431
+ PERMISSIVE_EXCEPTIONS,
432
+ COMPAT_MATRIX
433
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Dependency License Scanner - Orchestrator
3
+ * Auto-detects project type and delegates to appropriate plugin
4
+ */
5
+
6
+ const chalk = require('chalk')
7
+
8
+ // Import explainCompatibility for --explain flag support
9
+ const { explainCompatibility } = require('./compat-checker')
10
+
11
+ // Import ecosystem plugins
12
+ const nodePlugin = require('./plugins/node')
13
+ const cppPlugin = require('./plugins/cpp')
14
+ const rustPlugin = require('./plugins/rust')
15
+ const pythonPlugin = require('./plugins/python')
16
+ const goPlugin = require('./plugins/go')
17
+
18
+ // Plugin registry with priority ordering
19
+ // Priority: node > cpp > rust > python > go
20
+ const plugins = {
21
+ node: nodePlugin,
22
+ cpp: cppPlugin,
23
+ rust: rustPlugin,
24
+ python: pythonPlugin,
25
+ go: goPlugin
26
+ }
27
+
28
+ // Plugin detection order (first match wins)
29
+ const pluginOrder = ['node', 'cpp', 'rust', 'python', 'go']
30
+
31
+ /**
32
+ * Auto-detect project type and get the appropriate plugin
33
+ * @returns {{name: string, plugin: Object}|null} Detected plugin or null
34
+ */
35
+ function detectPlugin() {
36
+ for (const name of pluginOrder) {
37
+ const plugin = plugins[name]
38
+ if (plugin && plugin.detect()) {
39
+ return { name, plugin }
40
+ }
41
+ }
42
+ return null
43
+ }
44
+
45
+ /**
46
+ * Scan all dependencies for license compatibility
47
+ * Auto-detects project type and delegates to appropriate plugin
48
+ * @param {string} projectLicense - The project's license
49
+ * @returns {Promise<Object>} Scan results
50
+ */
51
+ async function scanDependencies(projectLicense) {
52
+ const detected = detectPlugin()
53
+
54
+ if (!detected) {
55
+ throw new Error('No supported package manager detected')
56
+ }
57
+
58
+ return await detected.plugin.scanDependencies(projectLicense)
59
+ }
60
+
61
+ /**
62
+ * Display conflict report to console
63
+ * @param {Object} scanResult - Scan results
64
+ * @param {string} projectLicense - The project's license
65
+ * @param {Object} options - Display options
66
+ * @param {boolean} options.explain - Show authoritative source citations
67
+ * @returns {boolean} True if conflicts found (incompatible licenses), false otherwise
68
+ */
69
+ function displayConflictReport(scanResult, projectLicense, options = {}) {
70
+ if (scanResult.incompatible === 0 && scanResult.unknown === 0) {
71
+ console.log(chalk.green(`\n✅ All ${scanResult.totalDependencies} dependencies compatible with ${projectLicense.toUpperCase()}!`))
72
+ return false // No conflicts
73
+ }
74
+
75
+ const issueCount = scanResult.incompatible + scanResult.unknown
76
+ const hasConflicts = scanResult.incompatible > 0
77
+
78
+ if (hasConflicts) {
79
+ console.log(chalk.red(`\n❌ ${issueCount} issue(s) found:\n`))
80
+ } else {
81
+ // Only warnings, no conflicts
82
+ console.log(chalk.yellow(`\n⚠️ ${issueCount} warning(s) found:\n`))
83
+ }
84
+
85
+ for (const issue of scanResult.issues) {
86
+ if (issue.type === 'conflict') {
87
+ console.log(chalk.red(`❌ ${issue.package} (${issue.license})`))
88
+ } else {
89
+ console.log(chalk.yellow(`⚠️ ${issue.package} (${issue.license})`))
90
+ }
91
+ console.log(chalk.gray(` ${issue.reason}`))
92
+ console.log(chalk.gray(` Location: ${issue.location}`))
93
+
94
+ // Show authoritative source citations when --explain is used
95
+ if (options.explain && issue.license && issue.license !== 'UNKNOWN') {
96
+ const explanation = explainCompatibility(projectLicense, issue.license)
97
+ // Extract source citation from explanation (after "📚 Source:")
98
+ const sourceMatch = explanation.match(/📚 Source: (.+?)(?:\n|$)/)
99
+ const urlMatch = explanation.match(/🔗 URL: (.+?)(?:\n|$)/)
100
+
101
+ if (sourceMatch || urlMatch) {
102
+ console.log(chalk.blue(' ────────────────────────'))
103
+ if (sourceMatch) {
104
+ console.log(chalk.blue(` 📚 ${sourceMatch[1]}`))
105
+ }
106
+ if (urlMatch) {
107
+ console.log(chalk.blue(` 🔗 ${urlMatch[1]}`))
108
+ }
109
+ }
110
+ }
111
+
112
+ console.log() // Blank line between issues
113
+ }
114
+
115
+ // Only return true if there are actual conflicts (incompatible licenses)
116
+ // Warnings (unknown licenses) should not block
117
+ return hasConflicts
118
+ }
119
+
120
+ // Re-export plugin functions for backward compatibility
121
+ // These are deprecated but kept for BC
122
+ const { parsePackageJson, extractLicense } = nodePlugin
123
+
124
+ module.exports = {
125
+ scanDependencies,
126
+ parsePackageJson,
127
+ extractLicense,
128
+ displayConflictReport,
129
+ // Additional exports for testing
130
+ detectPlugin
131
+ }