Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 37 additions & 23 deletions bin/check-licenses.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,52 @@
/**
* External dependencies
*/
import spawn from 'cross-spawn';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

/**
* Internal dependencies
*/
import { checkDeps, getLicenses } from '../packages/scripts/utils/license.js';
import {
checkDeps,
collectDeps,
readPackageJson,
} from '../packages/scripts/utils/license.js';

const ignored = [
'@ampproject/remapping',
// Jest internals with Apache-2.0 license - only used for testing, not distributed.
'bser',
'fb-watchman',
'walker',
];
const __dirname = path.dirname( fileURLToPath( import.meta.url ) );
const ROOT_DIR = path.resolve( __dirname, '..' );

/*
* `wp-scripts check-licenses` uses prod and dev dependencies of the package to scan for dependencies. With npm workspaces, workspace packages (the @wordpress/* packages) are not listed in the main package json and this approach does not work.
* This script checks licenses for production dependencies of packages that are
* shipped with WordPress (those with wpScript or wpScriptModuleExports in package.json).
*
* Instead, work from an npm query that uses some custom information in package.json files to declare packages that are shipped with WordPress (and must be GPLv2 compatible) or other files that may use more permissive licenses.
* It works independently of the package manager (npm, pnpm, etc.) by:
* 1. Reading package.json files to find wpScript packages
* 2. Reading their production dependencies
* 3. Resolving each dependency using Node's module resolution
* 4. Reading the license from each resolved package
*/

const licenses = getLicenses( true );
const query = `.workspace:attr([wpScript],[wpScriptModuleExports]) :is(.prod):not(${ licenses
.map( ( license ) => `[license=${ JSON.stringify( license ) }]` )
.join( ',' ) })`;

// Use `npm query` to grab a list of all the packages for workspaces.
const child = spawn.sync( 'npm', [ 'query', query ] );

const dependenciesToProcess = JSON.parse( child.stdout.toString() );

checkDeps( dependenciesToProcess, {
ignored,
const packagesDir = path.join( ROOT_DIR, 'packages' );
const depsMap = new Map();
const visited = new Set();

// Find all workspace packages with wpScript or wpScriptModuleExports
for ( const dir of fs.readdirSync( packagesDir ) ) {
const pkgDir = path.join( packagesDir, dir );
const pkgJson = readPackageJson( pkgDir );

if ( pkgJson?.wpScript || pkgJson?.wpScriptModuleExports ) {
collectDeps( pkgJson.dependencies, pkgDir, {
gpl2: true,
depsMap,
visited,
shouldSkip: ( depName ) => depName.startsWith( '@wordpress/' ),
} );
}
}

checkDeps( Array.from( depsMap.values() ), {
gpl2: true,
} );
48 changes: 29 additions & 19 deletions packages/scripts/scripts/check-licenses.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
/**
* External dependencies
*/
const spawn = require( 'cross-spawn' );

/**
* Internal dependencies
*/
const { getArgFromCLI, hasArgInCLI } = require( '../utils' );
const { checkDeps, getLicenses } = require( '../utils/license' );
const {
checkDeps,
collectDeps,
readPackageJson,
} = require( '../utils/license' );

/*
* WARNING: Changes to this file may inadvertently cause us to distribute code that
Expand All @@ -29,22 +28,33 @@ const ignored = hasArgInCLI( '--ignore' )
.map( ( moduleName ) => moduleName.trim() )
: [];

let query = '';
const cwd = process.cwd();
const pkgJson = readPackageJson( cwd );

if ( ! pkgJson ) {
process.stdout.write(
'Unable to find package.json in current directory.\n'
);
process.exit( 1 );
}

let depsToCheck = {};
if ( prod ) {
query += '.prod';
depsToCheck = pkgJson.dependencies || {};
} else if ( dev ) {
query += '.dev';
depsToCheck = pkgJson.devDependencies || {};
} else {
query += '*';
depsToCheck = {
...( pkgJson.dependencies || {} ),
...( pkgJson.devDependencies || {} ),
};
}

query += `:not(${ getLicenses( gpl2 )
.map( ( license ) => `[license=${ JSON.stringify( license ) }]` )
.join( ',' ) })`;

// Use `npm query` to grab a list of all the packages.
const child = spawn.sync( 'npm', [ 'query', query ] );

const packages = JSON.parse( child.stdout.toString() );
const depsMap = new Map();
collectDeps( depsToCheck, cwd, {
gpl2,
depsMap,
visited: new Set(),
} );

checkDeps( packages, { ignored, gpl2 } );
checkDeps( Array.from( depsMap.values() ), { ignored, gpl2 } );
167 changes: 155 additions & 12 deletions packages/scripts/utils/license.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* External dependencies
*/
const { existsSync, readFileSync, realpathSync } = require( 'node:fs' );
const path = require( 'node:path' );
const { createRequire, findPackageJSON } = require( 'node:module' );
const { pathToFileURL } = require( 'node:url' );
const chalk = require( 'chalk' );
const { existsSync, readFileSync } = require( 'node:fs' );

const ERROR_TEXT = chalk.reset.inverse.bold.red( ' ERROR ' );
const WARNING_TEXT = chalk.reset.inverse.bold.yellow( ' WARNING ' );
Expand Down Expand Up @@ -71,6 +71,7 @@ const gpl2CompatibleLicenses = [
'ISC',
'LGPL-2.1',
'MIT',
'MIT-0',
'MIT/X11',
'MPL-2.0',
'ODC-By-1.0',
Expand Down Expand Up @@ -208,18 +209,20 @@ function detectTypeFromLicenseText( licenseText ) {
const reportedPackages = new Set();

/**
* @param {string} path
* @param {string} depPath
* @param {Licenses} licenses
* @return {void}
*/
function checkDepLicense( path, licenses ) {
if ( ! path ) {
function checkDepLicense( depPath, licenses ) {
if ( ! depPath ) {
return;
}

const filename = path + '/package.json';
const filename = depPath + '/package.json';
if ( ! existsSync( filename ) ) {
process.stdout.write( `Unable to locate package.json in ${ path }.` );
process.stdout.write(
`Unable to locate package.json in ${ depPath }.`
);
process.exit( 1 );
}

Expand Down Expand Up @@ -349,17 +352,17 @@ function checkDeps( deps, options ) {
}

/**
* @param {string} path The path to the package.
* @param {string} depPath The path to the package.
* @return {boolean} true if the package has a license, false if it
*/
function detectTypeFromLicenseFiles( path ) {
function detectTypeFromLicenseFiles( depPath ) {
return licenseFiles.reduce( ( detectedType, licenseFile ) => {
// If another LICENSE file already had licenses in it, use those.
if ( detectedType ) {
return detectedType;
}

const licensePath = path + '/' + licenseFile;
const licensePath = depPath + '/' + licenseFile;

if ( existsSync( licensePath ) ) {
const licenseText = readFileSync( licensePath ).toString();
Expand All @@ -370,9 +373,149 @@ function detectTypeFromLicenseFiles( path ) {
}, false );
}

/**
* Resolve a package's path using Node's resolution algorithm.
* Works regardless of package manager (npm, pnpm, yarn, etc.).
*
* Uses `findPackageJSON` from node:module (Node.js 22.14.0+) when available,
* with a fallback for older versions.
*
* @param {string} packageName - Name of the package to resolve
* @param {string} fromDir - Directory to resolve from
* @return {string|null} Path to the package directory, or null if not found
*/
function resolvePackagePath( packageName, fromDir ) {
// Use findPackageJSON when available (Node.js 22.14.0+)
if ( findPackageJSON ) {
try {
const parentURL = pathToFileURL(
path.join( fromDir, 'package.json' )
);
const pkgJsonPath = findPackageJSON( packageName, parentURL );
if ( pkgJsonPath ) {
// Use realpathSync to resolve symlinks. Without this, transitive dep resolution fails
// because Node resolves from the symlink path, not the real store path where sibling deps are located.
return realpathSync( path.dirname( pkgJsonPath ) );
}
} catch {
// Package not found
}
return null;
}

// Fallback for older Node.js versions
const localRequire = createRequire( path.join( fromDir, 'package.json' ) );
try {
const resolved = localRequire.resolve(
`${ packageName }/package.json`
);
return realpathSync( path.dirname( resolved ) );
} catch {
// Some packages use `exports` in package.json without exporting
// `./package.json`. In that case, resolving `pkg/package.json` fails
// but resolving the main entry still works. Walk up from the resolved
// main entry to find the nearest package.json.
try {
Comment thread
manzoorwanijk marked this conversation as resolved.
const mainResolved = localRequire.resolve( packageName );
let dir = path.dirname( mainResolved );
while ( dir !== path.parse( dir ).root ) {
Comment thread
manzoorwanijk marked this conversation as resolved.
if ( path.basename( dir ) === 'node_modules' ) {
break;
}
if ( existsSync( path.join( dir, 'package.json' ) ) ) {
return realpathSync( dir );
}
dir = path.dirname( dir );
}
} catch {
// Package not found
}
return null;
}
}

/**
* Read package.json from a directory.
*
* @param {string} dir - Directory containing package.json
* @return {Object|null} Parsed package.json or null if not found
*/
function readPackageJson( dir ) {
try {
return JSON.parse(
readFileSync( path.join( dir, 'package.json' ), 'utf8' )
);
} catch {
return null;
}
}

/**
* Recursively collect production dependencies using Node's module resolution.
*
* @param {Object} deps - Dependencies object from package.json
* @param {string} fromDir - Directory to resolve from
* @param {Object} options
* @param {boolean} options.gpl2 - Only allow GPL2 compatible licenses
* @param {Map} options.depsMap - Map to accumulate discovered dependencies
* @param {Set} options.visited - Set of already-visited paths (prevents cycles)
* @param {Function} [options.shouldSkip] - Optional callback; return true to skip a dep by name
*/
function collectDeps( deps, fromDir, options ) {
if ( ! deps ) {
return;
}

const { gpl2, depsMap, visited, shouldSkip } = options;
const licenses = getLicenses( gpl2 );

for ( const depName of Object.keys( deps ) ) {
if ( shouldSkip && shouldSkip( depName ) ) {
continue;
}

const depPath = resolvePackagePath( depName, fromDir );
if ( ! depPath ) {
continue;
}

// Avoid infinite loops
if ( visited.has( depPath ) ) {
continue;
}
visited.add( depPath );

const depPkgJson = readPackageJson( depPath );
if ( ! depPkgJson ) {
continue;
}

const key = `${ depName }@${ depPkgJson.version }`;
if ( ! depsMap.has( key ) ) {
const license = depPkgJson.license;

// Skip if license is in the allowed list
if ( ! license || ! licenses.includes( license ) ) {
depsMap.set( key, {
name: depName,
version: depPkgJson.version,
path: depPath,
license,
} );
}
}

// Recursively check this package's dependencies
collectDeps( depPkgJson.dependencies, depPath, options );
}
}

module.exports = {
detectTypeFromLicenseText,
checkAllCompatible,
checkDeps,
collectDeps,
getLicenses,
resolvePackagePath,
readPackageJson,
};
Loading