Skip to content

Commit

Permalink
Add support in check-license for conjunctive (AND) licenses. (#46801)
Browse files Browse the repository at this point in the history
* Add support in `check-license` for conjunctive (AND) licenses.

Conjuctive licenses were being ignored in both the `package.json` and within
various LICENSE files. In the first case, this could lead to false negatives
$(e.g., 'MIT AND BSD' being treated as non-compatible). In the second case, the
implementation was such that only one license was returned (whichever detected
license occurred later in `licenseFileStrings`). Based on the ordering of that
list, this was likely to cause a false positive, because the non-compatible
'Apache-2.0' license occurs before any of the compatible licenses.

Progress on #38461.

* Add unit tests for checkAllCompatible

* Update CHANGELOG

* Tidy up docblock.
  • Loading branch information
bdurette authored Apr 17, 2023
1 parent 0571b73 commit 7fa9700
Show file tree
Hide file tree
Showing 8 changed files with 654 additions and 32 deletions.
4 changes: 4 additions & 0 deletions packages/scripts/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@

- Updated dependencies to require React 18 ([45235](https://github.com/WordPress/gutenberg/pull/45235))

### Enhancements

- License check script supports conjunctive (AND) licenses. ([46801](https://github.com/WordPress/gutenberg/pull/46801))

## 24.6.0 (2022-11-16)

## 24.5.0 (2022-11-02)
Expand Down
120 changes: 88 additions & 32 deletions packages/scripts/scripts/check-licenses.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ const licenses = [
/*
* Some packages don't included a license string in their package.json file, but they
* do have a license listed elsewhere. These files are checked for matching license strings.
* Only the first matching license file with a matching license string is considered.
*
* See: licenseFileStrings.
*/
const licenseFiles = [
'LICENCE',
Expand Down Expand Up @@ -227,6 +230,7 @@ function traverseDepTree( deps ) {

function detectTypeFromLicenseFiles( path ) {
return licenseFiles.reduce( ( detectedType, licenseFile ) => {
// If another LICENSE file already had licenses in it, use those.
if ( detectedType ) {
return detectedType;
}
Expand All @@ -235,30 +239,39 @@ function detectTypeFromLicenseFiles( path ) {

if ( existsSync( licensePath ) ) {
const licenseText = readFileSync( licensePath ).toString();

// Check if the file contains any of the strings in licenseFileStrings.
return Object.keys( licenseFileStrings ).reduce(
( stringDetectedType, licenseStringType ) => {
const licenseFileString =
licenseFileStrings[ licenseStringType ];

return licenseFileString.reduce(
( currentDetectedType, fileString ) => {
if ( licenseText.includes( fileString ) ) {
return licenseStringType;
}
return currentDetectedType;
},
stringDetectedType
);
},
detectedType
);
return detectTypeFromLicenseText( licenseText );
}

return detectedType;
}, false );
}

function detectTypeFromLicenseText( licenseText ) {
// Check if the file contains any of the strings in licenseFileStrings.
return Object.keys( licenseFileStrings ).reduce(
( stringDetectedType, licenseStringType ) => {
const licenseFileString = licenseFileStrings[ licenseStringType ];

return licenseFileString.reduce(
( currentDetectedType, fileString ) => {
if ( licenseText.includes( fileString ) ) {
if ( currentDetectedType ) {
return currentDetectedType.concat(
' AND ',
licenseStringType
);
}
return licenseStringType;
}
return currentDetectedType;
},
stringDetectedType
);
},
false
);
}

function checkDepLicense( path ) {
if ( ! path ) {
return;
Expand Down Expand Up @@ -294,11 +307,17 @@ function checkDepLicense( path ) {
licenseType = undefined;
}

if ( licenseType ) {
const allowed = licenses.find( ( allowedLicense ) =>
checkLicense( allowedLicense, licenseType )
);
if ( allowed ) {
if ( licenseType !== undefined ) {
let licenseTypes = [ licenseType ];
if ( licenseType.includes( ' AND ' ) ) {
licenseTypes = licenseType
.replace( /^\(*/g, '' )
.replace( /\)*$/, '' )
.split( ' AND ' )
.map( ( e ) => e.trim() );
}

if ( checkAllCompatible( licenseTypes, licenses ) ) {
return;
}
}
Expand All @@ -313,17 +332,54 @@ function checkDepLicense( path ) {
return;
}

// Now that we have a license to check, see if any of the allowed licenses match.
const allowed = licenses.find( ( allowedLicense ) =>
checkLicense( allowedLicense, detectedLicenseType )
let detectedLicenseTypes = [ detectedLicenseType ];
if ( detectedLicenseType.includes( ' AND ' ) ) {
detectedLicenseTypes = detectedLicenseType
.replace( /^\(*/g, '' )
.replace( /\)*$/, '' )
.split( ' AND ' )
.map( ( e ) => e.trim() );
}

if ( checkAllCompatible( detectedLicenseTypes, licenses ) ) {
return;
}

process.exitCode = 1;
process.stdout.write(
`${ ERROR } Module ${ packageInfo.name } has an incompatible license '${ licenseType }'.\n`
);
}

if ( ! allowed ) {
process.exitCode = 1;
process.stdout.write(
`${ ERROR } Module ${ packageInfo.name } has an incompatible license '${ licenseType }'.\n`
/**
* Check that all of the licenses for a package are compatible.
*
* This function is invoked when the licenses are a conjunctive ("AND") list of licenses.
* In that case, the software is only compatible if all of the licenses in the list are
* compatible.
*
* @param {Array} packageLicenses The licenses that a package is licensed under.
* @param {Array} compatibleLicenses The list of compatible licenses.
*
* @return {boolean} true if all of the packageLicenses appear in compatibleLicenses.
*/
function checkAllCompatible( packageLicenses, compatibleLicenses ) {
return packageLicenses.reduce( ( compatible, packageLicense ) => {
return (
compatible &&
compatibleLicenses.reduce(
( found, allowedLicense ) =>
found || checkLicense( allowedLicense, packageLicense ),
false
)
);
}
}, true );
}

traverseDepTree( topLevelDeps );

// Required for unit testing
module.exports = {
detectTypeFromLicenseText,
checkAllCompatible,
};
89 changes: 89 additions & 0 deletions packages/scripts/scripts/test/check-licenses.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* External dependencies
*/
const fs = require( 'fs' );
const path = require( 'path' );

/**
* Internal dependencies
*/
import {
detectTypeFromLicenseText,
checkAllCompatible,
} from '../check-licenses';

describe( 'detectTypeFromLicenseText', () => {
let licenseText;

it( "should return 'Apache 2.0' when the license text is the Apache 2.0 license", () => {
licenseText = fs
.readFileSync( path.resolve( __dirname, 'licenses/apache2.txt' ) )
.toString();

expect( detectTypeFromLicenseText( licenseText ) ).toBe( 'Apache-2.0' );
} );

it( "should return 'BSD' when the license text is the BSD 3-clause license", () => {
licenseText = fs
.readFileSync(
path.resolve( __dirname, 'licenses/bsd3clause.txt' )
)
.toString();

expect( detectTypeFromLicenseText( licenseText ) ).toBe( 'BSD' );
} );

it( "should return 'BSD-3-Clause-W3C' when the license text is the W3C variation of the BSD 3-clause license", () => {
licenseText = fs
.readFileSync( path.resolve( __dirname, 'licenses/w3cbsd.txt' ) )
.toString();

expect( detectTypeFromLicenseText( licenseText ) ).toBe(
'BSD-3-Clause-W3C'
);
} );

it( "should return 'MIT' when the license text is the MIT license", () => {
licenseText = fs
.readFileSync( path.resolve( __dirname, 'licenses/mit.txt' ) )
.toString();

expect( detectTypeFromLicenseText( licenseText ) ).toBe( 'MIT' );
} );

it( "should return 'Apache2 AND MIT' when the license text is Apache2 followed by MIT license", () => {
licenseText = fs
.readFileSync(
path.resolve( __dirname, 'licenses/apache2-mit.txt' )
)
.toString();

expect( detectTypeFromLicenseText( licenseText ) ).toBe(
'Apache-2.0 AND MIT'
);
} );
} );

describe( 'checkAllCompatible', () => {
it( "should return 'true' when single license is in the allowed list", () => {
expect( checkAllCompatible( [ 'B' ], [ 'A', 'B', 'C' ] ) ).toBe( true );
} );

it( "should return 'false' when single license is not in the allowed list", () => {
expect( checkAllCompatible( [ 'D' ], [ 'A', 'B', 'C' ] ) ).toBe(
false
);
} );

it( "should return 'true' when all licenses are in the allowed list", () => {
expect( checkAllCompatible( [ 'A', 'C' ], [ 'A', 'B', 'C' ] ) ).toBe(
true
);
} );

it( "should return 'false' when any license is not in the allowed list", () => {
expect( checkAllCompatible( [ 'A', 'D' ], [ 'A', 'B', 'C' ] ) ).toBe(
false
);
} );
} );
Loading

1 comment on commit 7fa9700

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flaky tests detected in 7fa9700.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/4720563862
📝 Reported issues:

Please sign in to comment.