-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
chore: Properly extract copyright information from bundled packages #45833
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| "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 | ||
| } | ||
|
|
||
| /** | ||
| * | ||
| * @param {webpack.Compilation} compilation | ||
| * @param {*} callback | ||
| * @returns | ||
| */ | ||
| async emitLicenses(compilation, callback) { | ||
| const moduleNames = (module) => module.modules?.map(moduleNames) ?? [module.name] | ||
| 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 += `\n- ${pkg.name}\n\t- version: ${pkg.version}\n\t- license: ${license}` | ||
| } | ||
| output += `\n\nSPDX-License-Identifier: ${[...licenses].sort().join(' AND ')}\n` | ||
| output += [...authors].sort().map((author) => `SPDX-FileCopyrightText: ${author}`).join('\n'); | ||
|
|
||
| compilation.emitAsset( | ||
| asset.split('?', 2)[0] + '.license', | ||
| new webpack.sources.RawSource(output), | ||
| ) | ||
| } | ||
|
|
||
| if (callback) { | ||
| return void callback() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| module.exports = WebpackSPDXPlugin; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.