222 строки
6.4 KiB
JavaScript
222 строки
6.4 KiB
JavaScript
'use strict'
|
|
|
|
/**
|
|
* Party inspired by https://github.com/FormidableLabs/webpack-stats-plugin
|
|
*
|
|
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
|
* SPDX-License-Identifier: MIT
|
|
*/
|
|
|
|
const { constants } = require('node:fs')
|
|
const fs = require('node:fs/promises')
|
|
const path = require('node:path')
|
|
const webpack = require('webpack')
|
|
|
|
class WebpackSPDXPlugin {
|
|
|
|
#options
|
|
|
|
/**
|
|
* @param {object} opts Parameters
|
|
* @param {Record<string, string>} opts.override Override licenses for packages
|
|
*/
|
|
constructor(opts = {}) {
|
|
this.#options = { override: {}, ...opts }
|
|
}
|
|
|
|
apply(compiler) {
|
|
compiler.hooks.thisCompilation.tap('spdx-plugin', (compilation) => {
|
|
// `processAssets` is one of the last hooks before frozen assets.
|
|
// We choose `PROCESS_ASSETS_STAGE_REPORT` which is the last possible
|
|
// stage after which to emit.
|
|
compilation.hooks.processAssets.tapPromise(
|
|
{
|
|
name: 'spdx-plugin',
|
|
stage: compilation.constructor.PROCESS_ASSETS_STAGE_REPORT,
|
|
},
|
|
() => this.emitLicenses(compilation),
|
|
)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Find the nearest package.json
|
|
* @param {string} dir Directory to start checking
|
|
*/
|
|
async #findPackage(dir) {
|
|
if (!dir || dir === '/' || dir === '.') {
|
|
return null
|
|
}
|
|
|
|
const packageJson = `${dir}/package.json`
|
|
try {
|
|
await fs.access(packageJson, constants.F_OK)
|
|
} catch (e) {
|
|
return await this.#findPackage(path.dirname(dir))
|
|
}
|
|
|
|
const { private: isPrivatePacket, name } = JSON.parse(await fs.readFile(packageJson))
|
|
// "private" is set in internal package.json which should not be resolved but the parent package.json
|
|
// Same if no name is set in package.json
|
|
if (isPrivatePacket === true || !name) {
|
|
return (await this.#findPackage(path.dirname(dir))) ?? packageJson
|
|
}
|
|
return packageJson
|
|
}
|
|
|
|
/**
|
|
* Emit licenses found in compilation to '.license' files
|
|
* @param {webpack.Compilation} compilation Webpack compilation object
|
|
* @param {*} callback Callback for old webpack versions
|
|
*/
|
|
async emitLicenses(compilation, callback) {
|
|
const logger = compilation.getLogger('spdx-plugin')
|
|
// cache the node packages
|
|
const packageInformation = new Map()
|
|
|
|
const warnings = new Set()
|
|
/** @type {Map<string, Set<webpack.Chunk>>} */
|
|
const sourceMap = new Map()
|
|
|
|
for (const chunk of compilation.chunks) {
|
|
for (const file of chunk.files) {
|
|
if (sourceMap.has(file)) {
|
|
sourceMap.get(file).add(chunk)
|
|
} else {
|
|
sourceMap.set(file, new Set([chunk]))
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [asset, chunks] of sourceMap.entries()) {
|
|
/** @type {Set<webpack.Module>} */
|
|
const modules = new Set()
|
|
/**
|
|
* @param {webpack.Module} module
|
|
*/
|
|
const addModule = (module) => {
|
|
if (module && !modules.has(module)) {
|
|
modules.add(module)
|
|
for (const dep of module.dependencies) {
|
|
addModule(compilation.moduleGraph.getModule(dep))
|
|
}
|
|
}
|
|
}
|
|
chunks.forEach((chunk) => chunk.getModules().forEach(addModule))
|
|
|
|
const sources = [...modules].map((module) => module.identifier())
|
|
.map((source) => {
|
|
const skipped = [
|
|
'delegated',
|
|
'external',
|
|
'container entry',
|
|
'ignored',
|
|
'remote',
|
|
'data:',
|
|
]
|
|
// Webpack sources that we can not infer license information or that is not included (external modules)
|
|
if (skipped.some((prefix) => source.startsWith(prefix))) {
|
|
return ''
|
|
}
|
|
// Internal webpack sources
|
|
if (source.startsWith('webpack/runtime')) {
|
|
return require.resolve('webpack')
|
|
}
|
|
// Handle webpack loaders
|
|
if (source.includes('!')) {
|
|
return source.split('!').at(-1)
|
|
}
|
|
if (source.includes('|')) {
|
|
return source
|
|
.split('|')
|
|
.filter((s) => s.startsWith(path.sep))
|
|
.at(0)
|
|
}
|
|
return source
|
|
})
|
|
.filter((s) => !!s)
|
|
.map((s) => s.split('?', 2)[0])
|
|
|
|
// Skip assets without modules, these are emitted by webpack plugins
|
|
if (sources.length === 0) {
|
|
logger.warn(`Skipping ${asset} because it does not contain any source information`)
|
|
continue
|
|
}
|
|
|
|
/** packages used by the current asset
|
|
* @type {Set<string>}
|
|
*/
|
|
const packages = new Set()
|
|
|
|
// packages is the list of packages used by the asset
|
|
for (const sourcePath of sources) {
|
|
const pkg = await this.#findPackage(path.dirname(sourcePath))
|
|
if (!pkg) {
|
|
logger.warn(`No package for source found (${sourcePath})`)
|
|
continue
|
|
}
|
|
|
|
if (!packageInformation.has(pkg)) {
|
|
// Get the information from the package
|
|
const { author: packageAuthor, name, version, license: packageLicense, licenses } = JSON.parse(await fs.readFile(pkg))
|
|
// Handle legacy packages
|
|
let license = !packageLicense && licenses
|
|
? licenses.map((entry) => entry.type ?? entry).join(' OR ')
|
|
: packageLicense
|
|
if (license?.includes(' ') && !license?.startsWith('(')) {
|
|
license = `(${license})`
|
|
}
|
|
// Handle both object style and string style author
|
|
const author = typeof packageAuthor === 'object'
|
|
? `${packageAuthor.name}` + (packageAuthor.mail ? ` <${packageAuthor.mail}>` : '')
|
|
: packageAuthor ?? `${name} developers`
|
|
|
|
packageInformation.set(pkg, {
|
|
version,
|
|
// Fallback to directory name if name is not set
|
|
name: name ?? path.basename(path.dirname(pkg)),
|
|
author,
|
|
license,
|
|
})
|
|
}
|
|
packages.add(pkg)
|
|
}
|
|
|
|
let output = 'This file is generated from multiple sources. Included packages:\n'
|
|
const authors = new Set()
|
|
const licenses = new Set()
|
|
for (const packageName of [...packages].sort()) {
|
|
const pkg = packageInformation.get(packageName)
|
|
const license = this.#options.override[pkg.name] ?? pkg.license
|
|
// Emit warning if not already done
|
|
if (!license && !warnings.has(pkg.name)) {
|
|
logger.warn(`Missing license information for package ${pkg.name}, you should add it to the 'override' option.`)
|
|
warnings.add(pkg.name)
|
|
}
|
|
licenses.add(license || 'unknown')
|
|
authors.add(pkg.author)
|
|
output += `- ${pkg.name}\n\t- version: ${pkg.version}\n\t- license: ${license}\n`
|
|
}
|
|
output = `\n\n${output}`
|
|
for (const author of [...authors].sort()) {
|
|
output = `SPDX-FileCopyrightText: ${author}\n${output}`
|
|
}
|
|
for (const license of [...licenses].sort()) {
|
|
output = `SPDX-License-Identifier: ${license}\n${output}`
|
|
}
|
|
|
|
compilation.emitAsset(
|
|
asset.split('?', 2)[0] + '.license',
|
|
new webpack.sources.RawSource(output),
|
|
)
|
|
}
|
|
|
|
if (callback) {
|
|
return callback()
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
module.exports = WebpackSPDXPlugin
|