/
githubmirror
/
gutenberg
Обзор
Документация
Войти
/
githubmirror
/
gutenberg
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
trunk
packages/scripts/utils/license.js
521 строка
14 KB
Kushagra Goyal
scripts: Fix path for license type detection in license.js (#78245)
13 май 2026, 22:57
Не верифицирован
13 май 2026, 22:57
e1d8635
Код
Авторство
О чём код?
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 ERROR_TEXT = chalk.reset.inverse.bold.red( ' ERROR ' ); const WARNING_TEXT = chalk.reset.inverse.bold.yellow( ' WARNING ' ); /** * @typedef {ReadonlyArray<string>} 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. * @type {ReadonlyArray<string>} */ const licenseFiles = [ 'LICENCE', 'license', 'LICENSE', 'LICENSE.md', 'LICENSE.txt', 'LICENSE-MIT', 'MIT-LICENSE.txt', 'Readme.md', 'README.md', ]; /** * When searching through files for licensing information, these are the strings we look for, * and their matching license. */ const licenseFileStrings = { 'Apache-2.0': [ 'Licensed under the Apache License, Version 2.0' ], BSD: [ 'Redistributions in binary form must reproduce the above copyright notice,', ], 'BSD-3-Clause-W3C': [ 'W3C 3-clause BSD License' ], MIT: [ 'Permission is hereby granted, free of charge,', '## License\n\nMIT', '## License\n\n MIT', ], }; /* * A list of license strings that we've found to be GPL2 compatible. * * Note the strings with "AND" in them at the bottom: these should only be added when * all the licenses in that string are GPL2 compatible. */ const gpl2CompatibleLicenses = [ '0BSD', 'Apache-2.0 WITH LLVM-exception', 'Artistic-2.0', 'BlueOak-1.0.0', 'BSD-2-Clause', 'BSD-3-Clause-W3C', 'BSD-3-Clause', 'BSD', 'CC-BY-4.0', 'CC0-1.0', 'GPL-2.0-or-later', 'GPL-2.0', 'GPL-2.0+', 'ISC', 'LGPL-2.1', 'MIT', 'MIT-0', 'MIT/X11', 'MPL-2.0', 'ODC-By-1.0', 'Public Domain', 'Unlicense', 'W3C-20150513', 'WTFPL', 'Zlib', ]; /* * A list of OSS license strings that aren't GPL2 compatible. * * We're cool with using packages that are licensed under any of these if we're not * distributing them (for example, build tools), but we can't included them in a release. */ const otherOssLicenses = [ 'Apache-2.0', 'Apache License, Version 2.0', 'CC-BY-3.0', 'CC-BY-SA-2.0', 'LGPL', 'Python-2.0', ]; /** * @param {boolean} gpl2 Only allow GPL2 compatible licenses. * @return {Licenses} Allowed licenses. */ const getLicenses = ( gpl2 ) => { return [ ...gpl2CompatibleLicenses, ...( gpl2 ? [] : otherOssLicenses ) ]; }; /** * Check if a license string matches the given license. * * The license string can be a single license, or an SPDX-compatible "OR" license string. * eg, "(MIT OR Zlib)". * * @param {string} allowedLicense The license that's allowed. * @param {string} licenseType The license string to check. * * @return {boolean} true if the licenseType matches the allowedLicense, false if it doesn't. */ const checkLicense = ( allowedLicense, licenseType ) => { if ( ! licenseType ) { return false; } // Some licenses have unusual capitalisation in them. const formattedAllowedLicense = allowedLicense.toLowerCase(); const formattedlicenseType = licenseType.toLowerCase(); if ( formattedAllowedLicense === formattedlicenseType ) { return true; } // We can skip the parsing below if there isn't an 'OR' in the license. if ( ! formattedlicenseType.includes( ' or ' ) ) { return false; } /* * In order to do a basic parse of SPDX-compatible "OR" license strings, we: * - Remove surrounding brackets: "(mit or zlib)" -> "mit or zlib" * - Split it into an array: "mit or zlib" -> [ "mit", "zlib" ] * - Trim any remaining whitespace from each element */ const subLicenseTypes = formattedlicenseType .replace( /^\(*/g, '' ) .replace( /\)*$/, '' ) .split( ' or ' ) .map( ( e ) => e.trim() ); // We can then check our array of licenses against the allowedLicense. return ( undefined !== subLicenseTypes.find( ( subLicenseType ) => checkLicense( allowedLicense, subLicenseType ) ) ); }; /** * 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 {Licenses} packageLicenses The licenses that a package is licensed under. * @param {Licenses} 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 ); } 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 ); } const reportedPackages = new Set(); /** * @param {string} depPath * @param {Licenses} licenses * @return {void} */ function checkDepLicense( depPath, licenses ) { if ( ! depPath ) { return; } const filename = depPath + '/package.json'; if ( ! existsSync( filename ) ) { process.stdout.write( `Unable to locate package.json in ${ depPath }.` ); process.exit( 1 ); } /* * The package.json format can be kind of weird. We allow for the following formats: * - { license: 'MIT' } * - { license: { type: 'MIT' } } * - { licenses: [ 'MIT', 'Zlib' ] } * - { licenses: [ { type: 'MIT' }, { type: 'Zlib' } ] } */ const packageInfo = require( filename ); const license = packageInfo.license || ( packageInfo.licenses && packageInfo.licenses.map( ( l ) => l.type || l ).join( ' OR ' ) ); let licenseType = typeof license === 'object' ? license.type : license; // Check if the license we've detected is telling us to look in the license file, instead. if ( licenseType && licenseFiles.find( ( licenseFile ) => licenseType.includes( licenseFile ) ) ) { licenseType = undefined; } 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; } } /* * If we haven't been able to detect a license in the package.json file, * or the type was invalid, try reading it from the files defined in * license files, instead. */ const detectedLicenseType = detectTypeFromLicenseFiles( depPath ); if ( ! licenseType && ! detectedLicenseType ) { return; } let detectedLicenseTypes = [ detectedLicenseType ]; if ( detectedLicenseType && detectedLicenseType.includes( ' AND ' ) ) { detectedLicenseTypes = detectedLicenseType .replace( /^\(*/g, '' ) .replace( /\)*$/, '' ) .split( ' AND ' ) .map( ( e ) => e.trim() ); } if ( checkAllCompatible( detectedLicenseTypes, licenses ) ) { return; } // Do not report same package twice. if ( reportedPackages.has( packageInfo.name ) ) { return; } reportedPackages.add( packageInfo.name ); process.exitCode = 1; process.stdout.write( `${ ERROR_TEXT } Module ${ packageInfo.name } has an incompatible license '${ licenseType }'.\n` ); } /** * * @typedef Options * @property {boolean} gpl2 Only allow GPL2 compatible licenses. * @property {Array<string>} [ignored] The list of ignored packages. */ /** * @param {Object} deps The dependencies tree. * @param {Options} options */ function checkDeps( deps, options ) { const licenses = getLicenses( options.gpl2 ); for ( const key in deps ) { const dep = deps[ key ]; if ( options.ignored?.includes( dep.name ) ) { continue; } // Don't check for licenses if the package entry is invalid. This can // happen if an incomplete package directory exists in node_modules. // Historically this could happen due to buggy behavior of Windows // environments in handling optional dependenciess if ( ! dep.name || Object.keys( dep ).length === 0 ) { continue; } if ( ! dep.hasOwnProperty( 'path' ) && ! dep.missing ) { if ( dep.hasOwnProperty( 'peerMissing' ) ) { process.stdout.write( `${ WARNING_TEXT } Unable to locate path for missing peer dep ${ dep.name }@${ dep.version }. ` ); } else { process.exitCode = 1; process.stdout.write( `${ ERROR_TEXT } Unable to locate path for ${ dep.name }@${ dep.version }. ` ); } } else if ( dep.missing ) { for ( const problem of dep.problems ) { process.stdout.write( `${ WARNING_TEXT } ${ problem }.\n` ); } } else { checkDepLicense( dep.path, licenses ); } } } /** * @param {string} depPath The path to the package. * @return {boolean} true if the package has a license, false if it */ 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 = depPath + '/' + licenseFile; if ( existsSync( licensePath ) ) { const licenseText = readFileSync( licensePath ).toString(); return detectTypeFromLicenseText( licenseText ); } return detectedType; }, 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 { const mainResolved = localRequire.resolve( packageName ); let dir = path.dirname( mainResolved ); while ( dir !== path.parse( dir ).root ) { 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, };