@companion-module/tools 2.7.2 → 2.8.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,539 @@
1
+ import parse from 'spdx-expression-parse'
2
+ import { correctedPackageLicense } from './known-package-licenses.js'
3
+
4
+ /**
5
+ * @typedef {import('./license-util.js').LegalInventory} LegalInventory
6
+ * @typedef {import('./license-util.js').ShippedPackageLegalRecord} ShippedPackageLegalRecord
7
+ */
8
+
9
+ /**
10
+ * @typedef {'connection' | 'surface'} ModuleType
11
+ */
12
+
13
+ /**
14
+ * @typedef {object} LicensePolicyIssue
15
+ * @property {string} packageName
16
+ * @property {string | undefined} [packageVersion]
17
+ * @property {ShippedPackageLegalRecord['kind']} packageKind
18
+ * @property {string} message
19
+ */
20
+
21
+ /**
22
+ * @typedef {{ allowed: boolean, incompatibleAnd: boolean }} Evaluation
23
+ * @typedef {Pick<NodeJS.WriteStream, 'write'>} LicensePolicyOutput
24
+ */
25
+
26
+ export class LicensePolicyError extends Error {
27
+ /** @param {LicensePolicyIssue[]} issues */
28
+ constructor(issues) {
29
+ super(`License validation failed with ${issues.length} error${issues.length === 1 ? '' : 's'}.`)
30
+ this.issues = issues
31
+ this.name = 'LicensePolicyError'
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Licenses a module may be declared as, each allowing a different set of dependency licenses
37
+ * @typedef {'MIT' | 'GPL-2.0-only' | 'GPL-3.0-only'} ProjectLicense
38
+ */
39
+
40
+ /** Permissive licenses which may be shipped by a module under any of the supported project licenses */
41
+ const PERMISSIVE_DEPENDENCY_LICENSES = [
42
+ 'MIT',
43
+ 'MIT-0',
44
+ 'ISC',
45
+ 'BSD-2-Clause',
46
+ 'BSD-3-Clause',
47
+ '0BSD',
48
+ 'CC0-1.0',
49
+ 'Unlicense',
50
+ 'BlueOak-1.0.0',
51
+ 'Python-2.0',
52
+ 'Zlib',
53
+ 'BSL-1.0',
54
+ // Not an SPDX identifier, SPDX has none for a public domain dedication. Recording it as CC0-1.0 or Unlicense
55
+ // would name a specific legal instrument the package never used, and public domain carries no obligation at all.
56
+ 'Public-Domain',
57
+ ]
58
+
59
+ const GPL2_DEPENDENCY_LICENSES = [
60
+ 'GPL-2.0-only',
61
+ 'GPL-2.0-or-later',
62
+ 'GPL-2.0', // Deprecated SPDX id for GPL-2.0-only, still widely declared on npm
63
+ ]
64
+
65
+ const GPL3_DEPENDENCY_LICENSES = [
66
+ 'GPL-3.0-only',
67
+ 'GPL-3.0-or-later',
68
+ 'GPL-3.0', // Deprecated SPDX id for GPL-3.0-only, still widely declared on npm
69
+ ]
70
+
71
+ // The LGPL families split by which GPL version their relicensing clause can reach. LGPL-2.x section 3 offers "version
72
+ // 2 of the ordinary GPL or any later version", so it suits either GPL policy, while LGPL-3.0 only reaches GPL-3.0.
73
+ const LGPL2_DEPENDENCY_LICENSES = [
74
+ 'LGPL-2.0-only',
75
+ 'LGPL-2.0-or-later',
76
+ 'LGPL-2.0', // Deprecated SPDX id for LGPL-2.0-only, still widely declared on npm
77
+ 'LGPL-2.1-only',
78
+ 'LGPL-2.1-or-later',
79
+ 'LGPL-2.1', // Deprecated SPDX id for LGPL-2.1-only
80
+ ]
81
+
82
+ const LGPL3_DEPENDENCY_LICENSES = [
83
+ 'LGPL-3.0-only',
84
+ 'LGPL-3.0-or-later',
85
+ 'LGPL-3.0', // Deprecated SPDX id for LGPL-3.0-only
86
+ ]
87
+
88
+ // Each policy pins the combined work to a single GPL version, so no mix of allowed dependencies can end up
89
+ // unlicensable. Offering GPL-2.0-or-later would break that, a GPL-2.0-only and a GPL-3.0-only dependency could then
90
+ // both be accepted while no version satisfies both.
91
+ /** @type {Record<ProjectLicense, { allowedDependencyLicenses: Set<string>, externalOnlyLicenses: Set<string> }>} */
92
+ const PROJECT_LICENSE_POLICIES = {
93
+ MIT: {
94
+ allowedDependencyLicenses: new Set([
95
+ ...PERMISSIVE_DEPENDENCY_LICENSES,
96
+ 'Apache-2.0',
97
+ 'CC-BY-3.0',
98
+ 'CC-BY-4.0',
99
+ 'MPL-2.0', // File level copyleft, allows bundling if source is available (links bundled LICENSE)
100
+ ]),
101
+ // An MIT bundle cannot offer the relinking static linking would require, but the LGPL is written to let a
102
+ // work under any terms use the library as a separate one, so every LGPL version is fine as an external
103
+ externalOnlyLicenses: new Set([...LGPL2_DEPENDENCY_LICENSES, ...LGPL3_DEPENDENCY_LICENSES]),
104
+ },
105
+ // Apache-2.0 and the CC-BY licenses are deliberately absent, they cannot be combined with GPL-2.0-only
106
+ 'GPL-2.0-only': {
107
+ allowedDependencyLicenses: new Set([
108
+ ...PERMISSIVE_DEPENDENCY_LICENSES,
109
+ ...GPL2_DEPENDENCY_LICENSES,
110
+ // Section 3 relicenses these to GPL-2.0, so the bundle stays distributable under this policy
111
+ ...LGPL2_DEPENDENCY_LICENSES,
112
+ 'MPL-2.0',
113
+ ]),
114
+ // LGPL-3.0 is absent entirely rather than external only. It is GPL-3.0 plus permissions, so it carries terms
115
+ // GPL-2.0 section 6 forbids adding, and linking still forms a combined work whichever way it is shipped.
116
+ externalOnlyLicenses: new Set(),
117
+ },
118
+ 'GPL-3.0-only': {
119
+ allowedDependencyLicenses: new Set([
120
+ ...PERMISSIVE_DEPENDENCY_LICENSES,
121
+ ...GPL3_DEPENDENCY_LICENSES,
122
+ 'GPL-2.0-or-later', // GPL-2.0-only is absent, only the "or later" form can be taken to GPL-3.0
123
+ // Both families reach GPL-3.0, the 2.x one through its "or any later version" offer
124
+ ...LGPL2_DEPENDENCY_LICENSES,
125
+ ...LGPL3_DEPENDENCY_LICENSES,
126
+ 'Apache-2.0',
127
+ 'MPL-2.0',
128
+ ]),
129
+ externalOnlyLicenses: new Set(),
130
+ },
131
+ }
132
+
133
+ export const SUPPORTED_PROJECT_LICENSES = /** @type {ProjectLicense[]} */ (Object.keys(PROJECT_LICENSE_POLICIES))
134
+
135
+ /**
136
+ * Policy applied to dependencies while the module itself has no usable license declaration
137
+ * @type {ProjectLicense}
138
+ */
139
+ const FALLBACK_PROJECT_LICENSE = 'MIT'
140
+
141
+ /**
142
+ * Suggested to module authors, as the one which can use the most of npm and be reused most freely
143
+ * @type {ProjectLicense}
144
+ */
145
+ const RECOMMENDED_PROJECT_LICENSE = 'MIT'
146
+
147
+ const LICENSE_HELP_MESSAGE =
148
+ 'Not sure what to do about these? Ask in the Bitfocus community Slack, we are happy to help you work out what they mean for your module.'
149
+
150
+ /**
151
+ * @param {string} declaration
152
+ * @returns {declaration is ProjectLicense}
153
+ */
154
+ function isProjectLicense(declaration) {
155
+ return /** @type {string[]} */ (SUPPORTED_PROJECT_LICENSES).includes(declaration)
156
+ }
157
+
158
+ /**
159
+ * @param {string[]} items
160
+ * @returns {string}
161
+ */
162
+ function orList(items) {
163
+ const remaining = [...items]
164
+ const last = remaining.pop()
165
+ return remaining.length ? `${remaining.join(', ')} or ${last}` : `${last}`
166
+ }
167
+
168
+ /** @returns {string} */
169
+ function supportedProjectLicenseAdvice() {
170
+ const alternatives = SUPPORTED_PROJECT_LICENSES.filter((license) => license !== RECOMMENDED_PROJECT_LICENSE)
171
+ return `We recommend ${RECOMMENDED_PROJECT_LICENSE} for the widest compatibility, but also accept ${orList(alternatives)} when necessary.`
172
+ }
173
+
174
+ /**
175
+ * @param {import('spdx-expression-parse').Info} node
176
+ * @param {Set<string>} allowedLicenses
177
+ * @returns {Evaluation}
178
+ */
179
+ function evaluate(node, allowedLicenses) {
180
+ if ('license' in node) {
181
+ const license = node.plus ? `${node.license}-or-later` : node.license // "GPL-2.0+" is deprecated for "GPL-2.0-or-later"
182
+ return {
183
+ allowed: !node.exception && allowedLicenses.has(license),
184
+ incompatibleAnd: false,
185
+ }
186
+ }
187
+
188
+ const left = evaluate(node.left, allowedLicenses)
189
+ const right = evaluate(node.right, allowedLicenses)
190
+ if (node.conjunction === 'or') {
191
+ const allowed = left.allowed || right.allowed
192
+ return {
193
+ allowed,
194
+ incompatibleAnd: !allowed && (left.incompatibleAnd || right.incompatibleAnd),
195
+ }
196
+ }
197
+
198
+ return {
199
+ allowed: left.allowed && right.allowed,
200
+ incompatibleAnd: left.allowed !== right.allowed || left.incompatibleAnd || right.incompatibleAnd,
201
+ }
202
+ }
203
+
204
+ /**
205
+ * spdx-expression-parse rejects the whole expression when any leaf is not a valid SPDX identifier, so a legacy
206
+ * declaration like "(MIT OR GPL)" never reaches the OR logic at all. Re-read those keeping only the boolean
207
+ * structure, so an unrecognised identifier becomes a license which is simply never allowed. OR then still offers a
208
+ * real choice, and AND still requires every branch, so nothing unknown can be accepted on its own.
209
+ *
210
+ * @param {string} declaration
211
+ * @returns {import('spdx-expression-parse').Info | undefined}
212
+ */
213
+ function parseLenient(declaration) {
214
+ const tokens = declaration.match(/\(|\)|[^\s()]+/g)
215
+ if (!tokens) return undefined
216
+ let index = 0
217
+ const keyword = (token, word) => token !== undefined && token.toUpperCase() === word
218
+
219
+ function parseExpression() {
220
+ let node = parseTerm()
221
+ if (!node) return undefined
222
+ while (keyword(tokens[index], 'OR')) {
223
+ index++
224
+ const right = parseTerm()
225
+ if (!right) return undefined
226
+ node = { left: node, conjunction: 'or', right }
227
+ }
228
+ return node
229
+ }
230
+
231
+ function parseTerm() {
232
+ let node = parseFactor()
233
+ if (!node) return undefined
234
+ while (keyword(tokens[index], 'AND')) {
235
+ index++
236
+ const right = parseFactor()
237
+ if (!right) return undefined
238
+ node = { left: node, conjunction: 'and', right }
239
+ }
240
+ return node
241
+ }
242
+
243
+ function parseFactor() {
244
+ const token = tokens[index]
245
+ if (token === undefined || token === ')') return undefined
246
+ if (token === '(') {
247
+ index++
248
+ const node = parseExpression()
249
+ if (!node || tokens[index] !== ')') return undefined
250
+ index++
251
+ return node
252
+ }
253
+ if (keyword(token, 'AND') || keyword(token, 'OR') || keyword(token, 'WITH')) return undefined
254
+ index++
255
+
256
+ const plus = token.endsWith('+')
257
+ const node = { license: plus ? token.slice(0, -1) : token, plus }
258
+ if (keyword(tokens[index], 'WITH')) {
259
+ index++
260
+ if (tokens[index] === undefined) return undefined
261
+ node.exception = tokens[index]
262
+ index++
263
+ }
264
+ return node
265
+ }
266
+
267
+ const node = parseExpression()
268
+ return node && index === tokens.length ? node : undefined
269
+ }
270
+
271
+ /**
272
+ * @param {string} declaration
273
+ * @returns {{ node: import('spdx-expression-parse').Info, strict: boolean } | undefined}
274
+ */
275
+ function parseDeclaration(declaration) {
276
+ try {
277
+ return { node: parse(declaration), strict: true }
278
+ } catch {
279
+ const node = parseLenient(declaration)
280
+ return node ? { node, strict: false } : undefined
281
+ }
282
+ }
283
+
284
+ /**
285
+ * @param {ShippedPackageLegalRecord} packageInfo
286
+ * @returns {string}
287
+ */
288
+ function normalizedDeclaration(packageInfo) {
289
+ return packageInfo.declaredLicense?.trim() ?? ''
290
+ }
291
+
292
+ /**
293
+ * @param {ShippedPackageLegalRecord} packageInfo
294
+ * @returns {string}
295
+ */
296
+ function packageLabel(packageInfo) {
297
+ return `${packageInfo.name}${packageInfo.version ? `@${packageInfo.version}` : ''}`
298
+ }
299
+
300
+ /**
301
+ * @param {string} declaration
302
+ * @returns {string}
303
+ */
304
+ function displayDeclaration(declaration) {
305
+ return declaration.replace(/[\u0000-\u001f\u007f]/g, (character) => {
306
+ switch (character) {
307
+ case '\r':
308
+ return '\\r'
309
+ case '\n':
310
+ return '\\n'
311
+ case '\t':
312
+ return '\\t'
313
+ default:
314
+ return `\\u${character.codePointAt(0).toString(16).padStart(4, '0')}`
315
+ }
316
+ })
317
+ }
318
+
319
+ /**
320
+ * @param {Evaluation} evaluation
321
+ * @returns {string}
322
+ */
323
+ function ambiguitySuffix(evaluation) {
324
+ return evaluation.incompatibleAnd
325
+ ? ' Both licenses may apply, making this declaration ambiguous and incompatible. Ask package author to use OR if either license may be chosen, or clarify package licensing.'
326
+ : ''
327
+ }
328
+
329
+ /**
330
+ * @param {ShippedPackageLegalRecord} a
331
+ * @param {ShippedPackageLegalRecord} b
332
+ * @returns {number}
333
+ */
334
+ function packageSort(a, b) {
335
+ if (a.kind === 'project' && b.kind !== 'project') return -1
336
+ if (a.kind !== 'project' && b.kind === 'project') return 1
337
+ return (
338
+ a.name.localeCompare(b.name) ||
339
+ (a.version ?? '').localeCompare(b.version ?? '') ||
340
+ normalizedDeclaration(a).localeCompare(normalizedDeclaration(b)) ||
341
+ a.kind.localeCompare(b.kind)
342
+ )
343
+ }
344
+
345
+ /**
346
+ * @param {ShippedPackageLegalRecord} packageInfo
347
+ * @param {string} message
348
+ * @returns {LicensePolicyIssue}
349
+ */
350
+ function issue(packageInfo, message) {
351
+ return {
352
+ packageName: packageInfo.name,
353
+ packageVersion: packageInfo.version,
354
+ packageKind: packageInfo.kind,
355
+ message,
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Resolve the license the packaged module is distributed under, which selects the policy applied to its dependencies
361
+ * @param {LegalInventory} inventory
362
+ * @returns {{ declaration: string, license: ProjectLicense | undefined }}
363
+ */
364
+ export function resolveProjectLicense(inventory) {
365
+ const projectPackage = inventory.packages.find((packageInfo) => packageInfo.kind === 'project')
366
+ const declaration = projectPackage ? normalizedDeclaration(projectPackage) : ''
367
+ return { declaration, license: isProjectLicense(declaration) ? declaration : undefined }
368
+ }
369
+
370
+ // For now module source must be MIT, so it stays portable whatever the packaged module is distributed as. Relaxing
371
+ // this means checking the source license is compatible with the distribution license instead of equal to this one.
372
+ const REQUIRED_SOURCE_LICENSE = 'MIT'
373
+
374
+ /**
375
+ * @param {ShippedPackageLegalRecord} packageInfo
376
+ * @returns {LicensePolicyIssue | undefined}
377
+ */
378
+ function createSourceLicenseIssue(packageInfo) {
379
+ const sourceLicense = packageInfo.sourceLicense?.trim() ?? ''
380
+ if (sourceLicense === REQUIRED_SOURCE_LICENSE) return undefined
381
+ if (!sourceLicense) {
382
+ return issue(
383
+ packageInfo,
384
+ `Your module source does not declare a license in package.json, but the Companion project requires module source to be ${REQUIRED_SOURCE_LICENSE} so it stays portable.`,
385
+ )
386
+ }
387
+ return issue(
388
+ packageInfo,
389
+ `Your module source is licensed as ${displayDeclaration(sourceLicense)} in package.json, but the Companion project requires module source to be ${REQUIRED_SOURCE_LICENSE} so it stays portable. Declare the license the module is distributed under in companion/manifest.json instead.`,
390
+ )
391
+ }
392
+
393
+ /**
394
+ * @param {ShippedPackageLegalRecord} packageInfo
395
+ * @returns {LicensePolicyIssue | undefined}
396
+ */
397
+ function createDistributionLicenseIssue(packageInfo) {
398
+ const declaration = normalizedDeclaration(packageInfo)
399
+ if (!declaration) {
400
+ return issue(
401
+ packageInfo,
402
+ `Your module does not declare a license in companion/manifest.json. ${supportedProjectLicenseAdvice()} Talk to us if you have a reason to use a different license.`,
403
+ )
404
+ }
405
+ if (isProjectLicense(declaration)) return undefined
406
+
407
+ let reason = 'which is not supported'
408
+ try {
409
+ // A declaration with a conjunction is dual licensing, which a module must pick a single license from
410
+ if (!('license' in parse(declaration))) reason = 'and dual licensing is not supported'
411
+ } catch {
412
+ // Report the declaration as unsupported, even when SPDX parsing fails.
413
+ }
414
+ return issue(
415
+ packageInfo,
416
+ `Your module is published under ${displayDeclaration(declaration)}, ${reason}. ${supportedProjectLicenseAdvice()} Talk to us if you have a reason to use a different license.`,
417
+ )
418
+ }
419
+
420
+ /**
421
+ * The distribution and source licenses are declared in different files, and are reported independently
422
+ * @param {ShippedPackageLegalRecord} packageInfo
423
+ * @returns {LicensePolicyIssue[]}
424
+ */
425
+ function createProjectIssues(packageInfo) {
426
+ return [createDistributionLicenseIssue(packageInfo), createSourceLicenseIssue(packageInfo)].filter(
427
+ (projectIssue) => projectIssue !== undefined,
428
+ )
429
+ }
430
+
431
+ /**
432
+ * @param {LegalInventory} inventory
433
+ * @returns {LicensePolicyIssue[]}
434
+ */
435
+ export function createLicensePolicyIssues(inventory) {
436
+ /** @type {LicensePolicyIssue[]} */
437
+ const result = []
438
+ const seen = new Set()
439
+
440
+ const projectLicense = resolveProjectLicense(inventory).license ?? FALLBACK_PROJECT_LICENSE
441
+ const policy = PROJECT_LICENSE_POLICIES[projectLicense]
442
+ const bundledLicenses = policy.allowedDependencyLicenses
443
+ // Identical to the bundled set where the policy has nothing which only works as an external, so that no issue is
444
+ // ever reported as fixable by externalising it when externalising would not in fact help
445
+ const externalLicenses = policy.externalOnlyLicenses.size
446
+ ? new Set([...bundledLicenses, ...policy.externalOnlyLicenses])
447
+ : bundledLicenses
448
+
449
+ for (const packageInfo of [...inventory.packages].sort(packageSort)) {
450
+ const declaration = normalizedDeclaration(packageInfo)
451
+ const identity =
452
+ packageInfo.kind === 'project'
453
+ ? 'project'
454
+ : `dependency:${packageInfo.name}@${packageInfo.version ?? ''}:${declaration}`
455
+ if (seen.has(identity)) continue
456
+ seen.add(identity)
457
+
458
+ if (packageInfo.kind === 'project') {
459
+ result.push(...createProjectIssues(packageInfo))
460
+ continue
461
+ }
462
+
463
+ if (!declaration) {
464
+ result.push(issue(packageInfo, `Dependency ${packageLabel(packageInfo)} has no declared license.`))
465
+ continue
466
+ }
467
+
468
+ let parsed = parseDeclaration(declaration)
469
+ // Only ever replaces a declaration which is not valid SPDX, so a correction can never launder a real license
470
+ if (!parsed || !parsed.strict) {
471
+ const corrected = correctedPackageLicense(packageInfo.name, packageInfo.version)
472
+ if (corrected) parsed = parseDeclaration(corrected) ?? parsed
473
+ }
474
+ if (!parsed) {
475
+ result.push(
476
+ issue(
477
+ packageInfo,
478
+ `Dependency ${packageLabel(packageInfo)} has unparseable license declaration ${displayDeclaration(declaration)}.`,
479
+ ),
480
+ )
481
+ continue
482
+ }
483
+
484
+ const allowedLicenses = packageInfo.kind === 'external' ? externalLicenses : bundledLicenses
485
+ const evaluation = evaluate(parsed.node, allowedLicenses)
486
+ if (evaluation.allowed) continue
487
+
488
+ // Only reachable for a bundled package, externals are already evaluated against the wider set
489
+ if (evaluate(parsed.node, externalLicenses).allowed) {
490
+ result.push(
491
+ issue(
492
+ packageInfo,
493
+ `Dependency ${packageLabel(packageInfo)} has license declaration ${displayDeclaration(declaration)} which may only be shipped as an external dependency, not built into the bundle. Add it to the externals in build-config.cjs so it is installed alongside the module and loaded at runtime.`,
494
+ ),
495
+ )
496
+ continue
497
+ }
498
+
499
+ if (!parsed.strict) {
500
+ // Reaching here means no branch we could recognise was allowed, so the unrecognised ones are the problem
501
+ result.push(
502
+ issue(
503
+ packageInfo,
504
+ `Dependency ${packageLabel(packageInfo)} has license declaration ${displayDeclaration(declaration)} which is not a valid SPDX identifier, so it cannot be checked against the ${projectLicense} license policy. Ask the package author to declare a specific SPDX license.`,
505
+ ),
506
+ )
507
+ continue
508
+ }
509
+ result.push(
510
+ issue(
511
+ packageInfo,
512
+ `Dependency ${packageLabel(packageInfo)} has license declaration ${displayDeclaration(declaration)} which is not compatible with the ${projectLicense} license policy.${ambiguitySuffix(evaluation)}`,
513
+ ),
514
+ )
515
+ }
516
+ return result
517
+ }
518
+
519
+ /**
520
+ * @param {LegalInventory} inventory
521
+ * @param {{ ignoreLicenseRules?: boolean, stderr?: LicensePolicyOutput }} [options]
522
+ * @returns {void}
523
+ */
524
+ export function enforceLicensePolicy(inventory, options = {}) {
525
+ const issues = createLicensePolicyIssues(inventory)
526
+ if (issues.length === 0) return
527
+ const stderr = options.stderr ?? process.stderr
528
+ const prefix = options.ignoreLicenseRules ? 'LICENSE WARNING' : 'LICENSE ERROR'
529
+ for (const policyIssue of issues) stderr.write(`${prefix}: ${policyIssue.message}\n`)
530
+ if (options.ignoreLicenseRules) {
531
+ stderr.write(
532
+ `License validation ignored ${issues.length} error${issues.length === 1 ? '' : 's'} because --ignore-license-rules was provided.\n`,
533
+ )
534
+ return
535
+ }
536
+ stderr.write(`License validation failed with ${issues.length} error${issues.length === 1 ? '' : 's'}.\n`)
537
+ stderr.write(`${LICENSE_HELP_MESSAGE}\n`)
538
+ throw new LicensePolicyError(issues)
539
+ }