From be3ee04378d076a695b9aeaaec7a2ca2f7861a0b Mon Sep 17 00:00:00 2001 From: Mickael Jeanroy Date: Mon, 12 Aug 2019 22:08:05 +0200 Subject: [PATCH] feat: allow specifying license requirement and warn on license violations --- package.json | 4 +- src/index-rollup-legacy.js | 2 +- src/index-rollup-stable.js | 2 +- src/license-plugin-option.js | 1 + src/license-plugin.js | 124 ++++++++++++++++++++++++++--------- test/license-plugin.spec.js | 112 ++++++++++++++++++++++++++----- 6 files changed, 196 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index 799b65d9..c2654528 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "lodash": "4.17.15", "magic-string": "0.25.3", "mkdirp": "0.5.1", - "moment": "2.24.0" + "moment": "2.24.0", + "spdx-expression-validate": "2.0.0", + "spdx-satisfies": "5.0.0" }, "devDependencies": { "@babel/core": "7.5.5", diff --git a/src/index-rollup-legacy.js b/src/index-rollup-legacy.js index c283ad06..2e18bbcb 100644 --- a/src/index-rollup-legacy.js +++ b/src/index-rollup-legacy.js @@ -93,7 +93,7 @@ module.exports = (options = {}) => { * @return {void} */ ongenerate() { - plugin.exportThirdParties(); + plugin.scanThirdParties(); }, }; }; diff --git a/src/index-rollup-stable.js b/src/index-rollup-stable.js index c857f9a1..00cca7e7 100644 --- a/src/index-rollup-stable.js +++ b/src/index-rollup-stable.js @@ -67,7 +67,7 @@ module.exports = (options = {}) => { * @return {void} */ generateBundle() { - plugin.exportThirdParties(); + plugin.scanThirdParties(); }, }; }; diff --git a/src/license-plugin-option.js b/src/license-plugin-option.js index 688d2738..656b25c5 100644 --- a/src/license-plugin-option.js +++ b/src/license-plugin-option.js @@ -58,6 +58,7 @@ const SCHEMA = { Joi.func(), Joi.object().keys({ includePrivate: Joi.boolean(), + allow: Joi.string(), output: [ Joi.func(), Joi.string(), diff --git a/src/license-plugin.js b/src/license-plugin.js index 7c207611..7d637a0d 100644 --- a/src/license-plugin.js +++ b/src/license-plugin.js @@ -31,6 +31,9 @@ const _ = require('lodash'); const moment = require('moment'); const MagicString = require('magic-string'); const glob = require('glob'); +const spdxExpressionValidate = require('spdx-expression-validate'); +const spdxSatisfies = require('spdx-satisfies'); + const Dependency = require('./dependency.js'); const generateBlockComment = require('./generate-block-comment.js'); const licensePluginOptions = require('./license-plugin-option.js'); @@ -259,12 +262,14 @@ class LicensePlugin { } /** - * Generate third-party dependencies summary. + * Scan third-party dependencies, and: + * - Warn for license violations. + * - Generate summary. * * @param {boolean} includePrivate Flag that can be used to include / exclude private dependencies. * @return {void} */ - exportThirdParties() { + scanThirdParties() { const thirdParty = this._options.thirdParty; if (!thirdParty) { return; @@ -280,37 +285,15 @@ class LicensePlugin { return thirdParty(outputDependencies); } - const output = thirdParty.output; - if (!output) { - return; + const allow = thirdParty.allow; + if (allow) { + this._scanLicenseViolations(outputDependencies, allow); } - if (_.isFunction(output)) { - return output(outputDependencies); + const output = thirdParty.output; + if (output) { + return this._exportThirdParties(outputDependencies, output); } - - // Default is to export to given file. - - // Allow custom formatting of output using given template option. - const template = _.isString(output.template) ? (dependencies) => _.template(output.template)({dependencies, _, moment}) : output.template; - const defaultTemplate = (dependencies) => ( - _.isEmpty(dependencies) ? 'No third parties dependencies' : _.map(dependencies, (d) => d.text()).join(`${EOL}${EOL}---${EOL}${EOL}`) - ); - - const text = _.isFunction(template) ? template(outputDependencies) : defaultTemplate(outputDependencies); - const isOutputFile = _.isString(output); - const file = isOutputFile ? output : output.file; - const encoding = isOutputFile ? 'utf-8' : (output.encoding || 'utf-8'); - - this.debug(`exporting third-party summary to ${file}`); - this.debug(`use encoding: ${encoding}`); - - // Create directory if it does not already exist. - mkdirp.sync(path.parse(file).dir); - - fs.writeFileSync(file, (text || '').trim(), { - encoding, - }); } /** @@ -321,10 +304,20 @@ class LicensePlugin { */ debug(msg) { if (this._debug) { - console.log(`[${this.name}] -- ${msg}`); + console.debug(`[${this.name}] -- ${msg}`); } } + /** + * Log warn message. + * + * @param {string} msg Log message. + * @return {void} + */ + warn(msg) { + console.warn(`[${this.name}] -- ${msg}`); + } + /** * Read banner from given options and returns it. * @@ -407,6 +400,75 @@ class LicensePlugin { return COMMENT_STYLES[style] ? generateBlockComment(text, COMMENT_STYLES[style]) : text; } + + /** + * Scan for dependency violations and print a warning if some violations are found. + * + * @param {Array} outputDependencies The dependencies to scan. + * @param {string} allow The allowed licenses as a SPDX pattern. + * @return {void} + */ + _scanLicenseViolations(outputDependencies, allow) { + _.forEach(outputDependencies, (dependency) => { + this._scanLicenseViolation(dependency, allow); + }); + } + + /** + * Scan dependency for a dependency violation. + * + * @param {Object} dependency The dependency to scan. + * @param {string} allow The allowed licenses as a SPDX pattern. + * @return {void} + */ + _scanLicenseViolation(dependency, allow) { + const license = dependency.license || 'UNLICENSED'; + if (license === 'UNLICENSED') { + this.warn(`Dependency "${dependency.name}" does not specify any license.`); + } else if (!spdxExpressionValidate(license) || !spdxSatisfies(license, allow)) { + this.warn( + `Dependency "${dependency.name}" has a license (${dependency.license}) which is not compatible with requirement (${allow}), ` + + `looks like a license violation to fix.` + ); + } + } + + /** + * Export scanned third party dependencies to a destination output (a function, a + * file written to disk, etc.). + * + * @param {Array} outputDependencies The dependencies to include in the output. + * @param {Object|function|string} output The output destination. + * @return {void} + */ + _exportThirdParties(outputDependencies, output) { + if (_.isFunction(output)) { + return output(outputDependencies); + } + + // Default is to export to given file. + + // Allow custom formatting of output using given template option. + const template = _.isString(output.template) ? (dependencies) => _.template(output.template)({dependencies, _, moment}) : output.template; + const defaultTemplate = (dependencies) => ( + _.isEmpty(dependencies) ? 'No third parties dependencies' : _.map(dependencies, (d) => d.text()).join(`${EOL}${EOL}---${EOL}${EOL}`) + ); + + const text = _.isFunction(template) ? template(outputDependencies) : defaultTemplate(outputDependencies); + const isOutputFile = _.isString(output); + const file = isOutputFile ? output : output.file; + const encoding = isOutputFile ? 'utf-8' : (output.encoding || 'utf-8'); + + this.debug(`exporting third-party summary to ${file}`); + this.debug(`use encoding: ${encoding}`); + + // Create directory if it does not already exist. + mkdirp.sync(path.parse(file).dir); + + fs.writeFileSync(file, (text || '').trim(), { + encoding, + }); + } } /** diff --git a/test/license-plugin.spec.js b/test/license-plugin.spec.js index 9bf45353..162a6594 100644 --- a/test/license-plugin.spec.js +++ b/test/license-plugin.spec.js @@ -1110,7 +1110,7 @@ describe('LicensePlugin', () => { }, }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); @@ -1155,7 +1155,7 @@ describe('LicensePlugin', () => { }, }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); fs.readFile(file, 'utf-8', (err, content) => { @@ -1208,7 +1208,7 @@ describe('LicensePlugin', () => { private: false, }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); @@ -1260,7 +1260,7 @@ describe('LicensePlugin', () => { license: 'MIT', }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); @@ -1295,7 +1295,7 @@ describe('LicensePlugin', () => { license: 'MIT', }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); @@ -1326,7 +1326,7 @@ describe('LicensePlugin', () => { instance._dependencies = {}; - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); @@ -1371,7 +1371,7 @@ describe('LicensePlugin', () => { private: true, }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); @@ -1425,7 +1425,7 @@ describe('LicensePlugin', () => { private: true, }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); @@ -1486,7 +1486,7 @@ describe('LicensePlugin', () => { }, }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); @@ -1528,7 +1528,7 @@ describe('LicensePlugin', () => { }, }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); @@ -1576,7 +1576,7 @@ describe('LicensePlugin', () => { }, }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); @@ -1620,7 +1620,7 @@ describe('LicensePlugin', () => { spyOn(fs, 'writeFileSync').and.callThrough(); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); expect(fs.writeFileSync).not.toHaveBeenCalled(); @@ -1652,7 +1652,7 @@ describe('LicensePlugin', () => { private: true, }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); expect(thirdParty).toHaveBeenCalledWith([ @@ -1704,7 +1704,7 @@ describe('LicensePlugin', () => { private: true, }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); expect(output).toHaveBeenCalledWith([ @@ -1758,7 +1758,7 @@ describe('LicensePlugin', () => { private: true, }); - const result = instance.exportThirdParties(); + const result = instance.scanThirdParties(); expect(result).not.toBeDefined(); expect(output).toHaveBeenCalledWith([ @@ -1794,4 +1794,86 @@ describe('LicensePlugin', () => { }, ]); }); + + it('should not warn without any license violations', () => { + const warn = spyOn(console, 'warn'); + const allow = '(Apache-2.0 OR MIT)'; + const instance = licensePlugin({ + thirdParty: { + allow, + }, + }); + + instance.addDependency({ + name: 'foo', + version: '1.0.0', + description: 'Foo Package', + license: 'Apache-2.0', + }); + + instance.addDependency({ + name: 'bar', + version: '2.0.0', + description: 'Bar Package', + license: 'MIT', + }); + + instance.scanThirdParties(); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('should warn for license violations', () => { + const warn = spyOn(console, 'warn'); + const allow = 'MIT'; + const instance = licensePlugin({ + thirdParty: { + allow, + }, + }); + + instance.addDependency({ + name: 'foo', + version: '1.0.0', + description: 'Foo Package', + license: 'Apache-2.0', + }); + + instance.addDependency({ + name: 'bar', + version: '2.0.0', + description: 'Bar Package', + license: 'MIT', + }); + + instance.scanThirdParties(); + + expect(warn).toHaveBeenCalledWith( + '[rollup-plugin-license] -- ' + + 'Dependency "foo" has a license (Apache-2.0) which is not compatible with requirement (MIT), ' + + 'looks like a license violation to fix.' + ); + }); + + it('should warn for unlicensed dependencies', () => { + const warn = spyOn(console, 'warn'); + const allow = '(Apache-2.0 OR MIT)'; + const instance = licensePlugin({ + thirdParty: { + allow, + }, + }); + + instance.addDependency({ + name: 'baz', + version: '3.0.0', + description: 'Baz Package', + }); + + instance.scanThirdParties(); + + expect(warn).toHaveBeenCalledWith( + '[rollup-plugin-license] -- Dependency "baz" does not specify any license.' + ); + }); });