licenseguard-cli 2.0.0 → 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.
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * License compatibility checker
3
3
  * Uses SPDX libraries for standard licenses + custom rules for non-SPDX
4
+ * Enhanced with authoritative compatibility matrix (FSF, Mozilla, Apache sources)
4
5
  */
5
6
 
6
- const satisfies = require('spdx-satisfies')
7
7
  const parse = require('spdx-expression-parse')
8
+ const { normalize, areSameLicense } = require('./license-normalizer')
9
+ const COMPAT_MATRIX = require('./license-compatibility-matrix.json')
8
10
 
9
11
  /**
10
12
  * Custom compatibility rules for non-SPDX licenses
@@ -19,31 +21,337 @@ const CUSTOM_COMPAT = {
19
21
  }
20
22
 
21
23
  /**
22
- * Permissive licenses that are generally compatible with each other
24
+ * Known copyleft license patterns that impose restrictions
25
+ * These require derivative works to use the same license
23
26
  */
24
- const PERMISSIVE_LICENSES = ['MIT', 'ISC', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', '0BSD']
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
+ ]
25
44
 
26
45
  /**
27
- * Copyleft licenses that have restrictions
46
+ * Permissive license exceptions that might contain misleading patterns
47
+ * These are ultra-permissive and should ALWAYS be allowed
28
48
  */
29
- const COPYLEFT_LICENSES = ['GPL-2.0', 'GPL-3.0', 'AGPL-3.0', 'GPL-2.0-only', 'GPL-3.0-only', 'LGPL-2.1', 'LGPL-3.0']
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
+ }
30
336
 
31
337
  /**
32
338
  * Check if two licenses are compatible
339
+ * Handles complex SPDX expressions with OR/AND
340
+ *
33
341
  * @param {string} projectLicense - The project's license
34
- * @param {string} depLicense - The dependency's license
342
+ * @param {string} depLicense - The dependency's license (can be SPDX expression)
35
343
  * @returns {{compatible: boolean, reason: string}} Compatibility result
36
344
  */
37
345
  function checkCompatibility(projectLicense, depLicense) {
38
346
  // Handle unknown licenses
39
347
  if (depLicense === 'UNKNOWN' || !depLicense) {
40
348
  return {
41
- compatible: false, // Warn but don't block (handled by caller)
349
+ compatible: false,
42
350
  reason: 'No license field found'
43
351
  }
44
352
  }
45
353
 
46
- // Handle custom licenses (WTFPL, proprietary, etc.)
354
+ // Handle custom licenses (WTFPL, etc.)
47
355
  const depLowerCase = depLicense.toLowerCase()
48
356
  if (CUSTOM_COMPAT[depLowerCase]) {
49
357
  if (CUSTOM_COMPAT[depLowerCase].compatibleWith === '*') {
@@ -51,64 +359,75 @@ function checkCompatibility(projectLicense, depLicense) {
51
359
  }
52
360
  }
53
361
 
54
- // Validate SPDX expressions
362
+ // Parse SPDX expression into AST
363
+ let ast
55
364
  try {
56
- parse(projectLicense)
57
- parse(depLicense)
365
+ ast = parse(depLicense)
58
366
  } catch (error) {
59
- // Invalid SPDX expression
60
367
  return {
61
368
  compatible: false,
62
369
  reason: `Invalid SPDX expression: ${depLicense}`
63
370
  }
64
371
  }
65
372
 
66
- // Same license is always compatible
67
- if (projectLicense === depLicense) {
68
- return { compatible: true, reason: 'Compatible' }
69
- }
70
-
71
- // Check if both are permissive licenses
72
- const projectIsPermissive = PERMISSIVE_LICENSES.includes(projectLicense)
73
- const depIsPermissive = PERMISSIVE_LICENSES.includes(depLicense)
373
+ // Evaluate AST recursively
374
+ return isCompatibleRecursive(projectLicense, ast)
375
+ }
74
376
 
75
- // Permissive licenses are compatible with each other
76
- if (projectIsPermissive && depIsPermissive) {
77
- return { compatible: true, reason: 'Compatible' }
78
- }
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)
79
395
 
80
- // Check for copyleft restrictions
81
- const depIsCopyleft = COPYLEFT_LICENSES.some(lic => depLicense.includes(lic))
396
+ let explanation = ''
82
397
 
83
- // Permissive project cannot use copyleft dependencies
84
- if (projectIsPermissive && depIsCopyleft) {
85
- return {
86
- compatible: false,
87
- reason: 'Copyleft incompatible with permissive license'
88
- }
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: '
89
405
  }
90
406
 
91
- // Copyleft project can use permissive dependencies
92
- const projectIsCopyleft = COPYLEFT_LICENSES.some(lic => projectLicense.includes(lic))
93
- if (projectIsCopyleft && depIsPermissive) {
94
- return { compatible: true, reason: 'Compatible' }
95
- }
407
+ // Reason
408
+ explanation += result.reason
96
409
 
97
- // For complex expressions, try spdx-satisfies
98
- try {
99
- const isCompatible = satisfies(depLicense, projectLicense)
100
- if (isCompatible) {
101
- return { compatible: true, reason: 'Compatible' }
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}`
102
416
  }
103
- } catch (error) {
104
- // Fall through to default incompatible
105
417
  }
106
418
 
107
- // Default: incompatible
108
- return {
109
- compatible: false,
110
- reason: `License ${depLicense} incompatible with ${projectLicense}`
111
- }
419
+ return explanation
112
420
  }
113
421
 
114
- module.exports = { checkCompatibility, CUSTOM_COMPAT }
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
+ }