@companion-module/tools 3.1.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/license/index.d.ts +10 -0
- package/dist/license/index.js +10 -0
- package/dist/license/inventory.d.ts +111 -0
- package/dist/license/inventory.js +427 -0
- package/dist/license/policy.d.ts +48 -0
- package/dist/license/policy.js +376 -0
- package/dist/scripts/lib/known-package-licenses.d.ts +3 -2
- package/dist/scripts/lib/known-package-licenses.js +5 -10
- package/dist/scripts/lib/license-util.d.ts +13 -52
- package/dist/scripts/lib/license-util.js +21 -389
- package/dist/scripts/lib/license-warning-util.d.ts +6 -22
- package/dist/scripts/lib/license-warning-util.js +16 -366
- package/package.json +14 -13
- package/tsconfig/node26/recommended.json +5 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.1.1](https://github.com/bitfocus/companion-module-tools/compare/v3.1.0...v3.1.1) (2026-10-01)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* update dependencies ([b4a95ad](https://github.com/bitfocus/companion-module-tools/commit/b4a95ad13727e20876a5ee1e60732efdc661ee74))
|
|
9
|
+
|
|
3
10
|
## [3.1.0](https://github.com/bitfocus/companion-module-tools/compare/v3.0.2...v3.1.0) (2026-08-28)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The license inventory and policy engine used by the connection and surface module tooling, exposed so that other
|
|
3
|
+
* parts of the Companion project which ship a bundle of npm dependencies can produce the same LICENSE file and apply
|
|
4
|
+
* the same compatibility rules to it.
|
|
5
|
+
*
|
|
6
|
+
* Consumers supply their own overrides (LicenseOverrides) and their own wording (LicensePolicyDescriptor), so that
|
|
7
|
+
* neither has to be maintained here on behalf of a repository this one knows nothing about.
|
|
8
|
+
*/
|
|
9
|
+
export * from './inventory.js';
|
|
10
|
+
export * from './policy.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The license inventory and policy engine used by the connection and surface module tooling, exposed so that other
|
|
3
|
+
* parts of the Companion project which ship a bundle of npm dependencies can produce the same LICENSE file and apply
|
|
4
|
+
* the same compatibility rules to it.
|
|
5
|
+
*
|
|
6
|
+
* Consumers supply their own overrides (LicenseOverrides) and their own wording (LicensePolicyDescriptor), so that
|
|
7
|
+
* neither has to be maintained here on behalf of a repository this one knows nothing about.
|
|
8
|
+
*/
|
|
9
|
+
export * from './inventory.js';
|
|
10
|
+
export * from './policy.js';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as esbuild from 'esbuild';
|
|
2
|
+
export type ShippedPackageKind = 'project' | 'bundled' | 'external';
|
|
3
|
+
export interface ShippedPackage {
|
|
4
|
+
kind: ShippedPackageKind;
|
|
5
|
+
name: string;
|
|
6
|
+
version?: string;
|
|
7
|
+
declaredLicense?: string;
|
|
8
|
+
/** Only for the project, the package.json license of its own source, which declaredLicense distributes it under */
|
|
9
|
+
sourceLicense?: string;
|
|
10
|
+
/** Where recipients can obtain the source of this package, as required by MPL-2.0 and the GPL family */
|
|
11
|
+
repositoryUrl?: string;
|
|
12
|
+
packageRoot: string;
|
|
13
|
+
contributingPaths: Set<string>;
|
|
14
|
+
}
|
|
15
|
+
export interface PackageCollection {
|
|
16
|
+
packages: ShippedPackage[];
|
|
17
|
+
diagnostics: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface LegalText {
|
|
20
|
+
role: 'license' | 'notice' | 'source-comment';
|
|
21
|
+
filename: string;
|
|
22
|
+
content: string;
|
|
23
|
+
sha256: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ShippedPackageLegalRecord extends ShippedPackage {
|
|
26
|
+
legalTexts: LegalText[];
|
|
27
|
+
}
|
|
28
|
+
export interface PackageLegalMaterial {
|
|
29
|
+
package: ShippedPackageLegalRecord;
|
|
30
|
+
diagnostics: string[];
|
|
31
|
+
}
|
|
32
|
+
export interface LegalInventory {
|
|
33
|
+
packages: ShippedPackageLegalRecord[];
|
|
34
|
+
diagnostics: string[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Per-consumer corrections to what npm packages declare about themselves. These are kept out of this package so that
|
|
38
|
+
* adding one does not need a release of the tooling, and so that each consumer only carries the entries its own
|
|
39
|
+
* dependency tree needs.
|
|
40
|
+
*/
|
|
41
|
+
export interface LicenseOverrides {
|
|
42
|
+
/**
|
|
43
|
+
* Licenses for packages which ship no license declaration at all, keyed by exact `name@version`. A declared
|
|
44
|
+
* license always wins, so an entry here can never hide what a package says about itself.
|
|
45
|
+
*/
|
|
46
|
+
knownPackageLicenses: Record<string, string>;
|
|
47
|
+
/**
|
|
48
|
+
* What the license text of a package actually is, for packages whose declaration is not a valid SPDX expression,
|
|
49
|
+
* keyed by exact `name@version`. Only consulted when the declaration cannot be parsed, so a correction can never
|
|
50
|
+
* turn a real license into a more convenient one.
|
|
51
|
+
*/
|
|
52
|
+
correctedPackageLicenses: Record<string, string>;
|
|
53
|
+
}
|
|
54
|
+
/** For consumers which have nothing to correct, and for tests */
|
|
55
|
+
export declare const NO_LICENSE_OVERRIDES: LicenseOverrides;
|
|
56
|
+
export declare function knownPackageLicense(overrides: LicenseOverrides, name: string | undefined, version: string | undefined): string | undefined;
|
|
57
|
+
export declare function correctedPackageLicense(overrides: LicenseOverrides, name: string | undefined, version: string | undefined): string | undefined;
|
|
58
|
+
export type PackageJson = {
|
|
59
|
+
name?: string;
|
|
60
|
+
version?: string;
|
|
61
|
+
license?: unknown;
|
|
62
|
+
licenses?: unknown;
|
|
63
|
+
repository?: unknown;
|
|
64
|
+
homepage?: unknown;
|
|
65
|
+
};
|
|
66
|
+
export declare function normalizeRepositoryUrl(packageJson: PackageJson): string | undefined;
|
|
67
|
+
export declare function normalizeInventoryPath(inputPath: string, ownerRoot: string): string;
|
|
68
|
+
export declare function readPackageJson(packageRoot: string): Promise<PackageJson>;
|
|
69
|
+
export declare function asLicenseString(license: unknown): string | undefined;
|
|
70
|
+
/** Reads a package's license, preferring the current field over the deprecated plural one */
|
|
71
|
+
export declare function declaredLicenseOf(packageJson: PackageJson, overrides: LicenseOverrides): string | undefined;
|
|
72
|
+
export declare function getContributingInputs(metafile: esbuild.Metafile): string[];
|
|
73
|
+
export declare function packageFromJson(kind: ShippedPackageKind, packageRoot: string, packageJson: PackageJson, overrides: LicenseOverrides): ShippedPackage;
|
|
74
|
+
export declare function collectInstalledPackages(nodeModulesDir: string, overrides: LicenseOverrides): Promise<ShippedPackage[]>;
|
|
75
|
+
export declare function normalizeLegalText(content: string): string;
|
|
76
|
+
export declare function isPathInside(childPath: string, parentPath: string): boolean;
|
|
77
|
+
export declare function collectPackageLegalMaterial(packageInfo: ShippedPackage): Promise<PackageLegalMaterial>;
|
|
78
|
+
export declare function renderLicenseFile(inventory: LegalInventory): string;
|
|
79
|
+
export declare function renderNoticeFile(inventory: LegalInventory): string | undefined;
|
|
80
|
+
export declare function writeLegalArtifacts(outputDir: string, inventory: LegalInventory): Promise<void>;
|
|
81
|
+
export declare function createLegalInventory(packages: ShippedPackage[]): Promise<LegalInventory>;
|
|
82
|
+
/** Identifies the project whose bundle is being inventoried, and the license it ships under */
|
|
83
|
+
export interface ProjectIdentity {
|
|
84
|
+
/**
|
|
85
|
+
* Directories which may contribute code. A file inside one of these, and not inside a node_modules directory, is
|
|
86
|
+
* the project's own code; anything outside all of them is reported as a diagnostic rather than attributed.
|
|
87
|
+
* A single-package project passes just its own directory, a monorepo passes its workspace root.
|
|
88
|
+
*/
|
|
89
|
+
projectRoots: string[];
|
|
90
|
+
/** The package.json identifying the project, which its own code is attributed to */
|
|
91
|
+
packageRoot: string;
|
|
92
|
+
/**
|
|
93
|
+
* What the project is called in the generated files, for a project whose package.json name is not the name the
|
|
94
|
+
* built artifact ships as. Undefined uses the name from that package.json.
|
|
95
|
+
*/
|
|
96
|
+
name: string | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* The license the built artifact is distributed under, which is what its dependencies must be compatible with.
|
|
99
|
+
* Undefined falls back to the license declared by the project package.json.
|
|
100
|
+
*/
|
|
101
|
+
distributionLicense: string | undefined;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Attributes each file which contributed to a bundle to the package that owns it. Callers which bundle with esbuild
|
|
105
|
+
* pass a metafile to collectMetafilePackages instead; this is the entrypoint for every other bundler.
|
|
106
|
+
*/
|
|
107
|
+
export declare function collectPackagesFromInputPaths(inputPaths: string[], project: ProjectIdentity, overrides: LicenseOverrides): Promise<PackageCollection>;
|
|
108
|
+
/** Attributes the inputs of an esbuild metafile to the packages that own them */
|
|
109
|
+
export declare function collectMetafilePackages(metafile: esbuild.Metafile,
|
|
110
|
+
/** The directory metafile input paths are relative to, which is esbuild's absWorkingDir */
|
|
111
|
+
absWorkingDir: string, project: ProjectIdentity, overrides: LicenseOverrides): Promise<PackageCollection>;
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import * as esbuild from 'esbuild';
|
|
5
|
+
/** For consumers which have nothing to correct, and for tests */
|
|
6
|
+
export const NO_LICENSE_OVERRIDES = { knownPackageLicenses: {}, correctedPackageLicenses: {} };
|
|
7
|
+
export function knownPackageLicense(overrides, name, version) {
|
|
8
|
+
if (!name || !version)
|
|
9
|
+
return undefined;
|
|
10
|
+
return overrides.knownPackageLicenses[`${name}@${version}`];
|
|
11
|
+
}
|
|
12
|
+
export function correctedPackageLicense(overrides, name, version) {
|
|
13
|
+
if (!name || !version)
|
|
14
|
+
return undefined;
|
|
15
|
+
return overrides.correctedPackageLicenses[`${name}@${version}`];
|
|
16
|
+
}
|
|
17
|
+
const SHORTHAND_REPOSITORY_HOSTS = {
|
|
18
|
+
github: 'https://github.com/',
|
|
19
|
+
gitlab: 'https://gitlab.com/',
|
|
20
|
+
bitbucket: 'https://bitbucket.org/',
|
|
21
|
+
};
|
|
22
|
+
export function normalizeRepositoryUrl(packageJson) {
|
|
23
|
+
const repository = packageJson.repository;
|
|
24
|
+
const declared = typeof repository === 'string'
|
|
25
|
+
? repository
|
|
26
|
+
: repository && typeof repository === 'object' && typeof repository.url === 'string'
|
|
27
|
+
? repository.url
|
|
28
|
+
: undefined;
|
|
29
|
+
let url = declared?.trim() ?? '';
|
|
30
|
+
if (url) {
|
|
31
|
+
// npm shorthands, either "user/repo" or "<host>:user/repo"
|
|
32
|
+
if (/^[\w.-]+\/[\w.-]+$/.test(url))
|
|
33
|
+
url = `${SHORTHAND_REPOSITORY_HOSTS.github}${url}`;
|
|
34
|
+
const shorthand = /^([a-z]+):([\w.-]+\/[\w.-]+)$/.exec(url);
|
|
35
|
+
if (shorthand && SHORTHAND_REPOSITORY_HOSTS[shorthand[1]]) {
|
|
36
|
+
url = `${SHORTHAND_REPOSITORY_HOSTS[shorthand[1]]}${shorthand[2]}`;
|
|
37
|
+
}
|
|
38
|
+
url = url.replace(/^git\+/, '').replace(/^(?:git|ssh):\/\/(?:git@)?/, 'https://');
|
|
39
|
+
const scpLike = /^git@([^:]+):(.+)$/.exec(url);
|
|
40
|
+
if (scpLike)
|
|
41
|
+
url = `https://${scpLike[1]}/${scpLike[2]}`;
|
|
42
|
+
url = url.replace(/\.git$/, '');
|
|
43
|
+
}
|
|
44
|
+
if (!/^https?:\/\//.test(url))
|
|
45
|
+
url = typeof packageJson.homepage === 'string' ? packageJson.homepage.trim() : '';
|
|
46
|
+
return /^https?:\/\//.test(url) ? url : undefined;
|
|
47
|
+
}
|
|
48
|
+
export function normalizeInventoryPath(inputPath, ownerRoot) {
|
|
49
|
+
const relativePath = path.relative(ownerRoot, inputPath);
|
|
50
|
+
if (relativePath === '' || relativePath === '.')
|
|
51
|
+
return '.';
|
|
52
|
+
if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
|
|
53
|
+
return '<outside package root>';
|
|
54
|
+
}
|
|
55
|
+
return relativePath.split(path.sep).join('/');
|
|
56
|
+
}
|
|
57
|
+
export async function readPackageJson(packageRoot) {
|
|
58
|
+
return JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
59
|
+
}
|
|
60
|
+
export function asLicenseString(license) {
|
|
61
|
+
if (typeof license === 'string')
|
|
62
|
+
return license;
|
|
63
|
+
// npm deprecated { type, url } and arrays of it long ago, but older packages are still published with them
|
|
64
|
+
if (Array.isArray(license)) {
|
|
65
|
+
const choices = license.map(asLicenseString).filter((choice) => choice !== undefined);
|
|
66
|
+
if (!choices.length)
|
|
67
|
+
return undefined;
|
|
68
|
+
return choices.length === 1 ? choices[0] : `(${choices.join(' OR ')})`; // An array offers a choice of licenses
|
|
69
|
+
}
|
|
70
|
+
if (license && typeof license === 'object')
|
|
71
|
+
return asLicenseString(license.type);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
/** Reads a package's license, preferring the current field over the deprecated plural one */
|
|
75
|
+
export function declaredLicenseOf(packageJson, overrides) {
|
|
76
|
+
return (asLicenseString(packageJson.license) ??
|
|
77
|
+
asLicenseString(packageJson.licenses) ??
|
|
78
|
+
// Only for packages declaring nothing at all, so this can never override what a package says about itself
|
|
79
|
+
knownPackageLicense(overrides, packageJson.name, packageJson.version));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The package a file belongs to, or undefined when it is not inside an installed package. A package owns everything
|
|
83
|
+
* below the directory node_modules resolves it to. Its own subdirectories can hold a package.json marking their
|
|
84
|
+
* module type, sometimes with a name and version, so only this boundary identifies it.
|
|
85
|
+
*/
|
|
86
|
+
async function findInstalledPackageRoot(inputPath) {
|
|
87
|
+
const segments = inputPath.split(path.sep);
|
|
88
|
+
const lastNodeModules = segments.lastIndexOf('node_modules');
|
|
89
|
+
if (lastNodeModules === -1)
|
|
90
|
+
return undefined;
|
|
91
|
+
const rootIndex = lastNodeModules + 1;
|
|
92
|
+
const packageRoot = segments
|
|
93
|
+
.slice(0, segments[rootIndex]?.startsWith('@') ? rootIndex + 2 : rootIndex + 1)
|
|
94
|
+
.join(path.sep);
|
|
95
|
+
try {
|
|
96
|
+
await readPackageJson(packageRoot);
|
|
97
|
+
return packageRoot;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return undefined; // Without a package.json this is not a package, so the file counts as project code
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function getContributingInputs(metafile) {
|
|
104
|
+
const inputs = new Set();
|
|
105
|
+
for (const [outputPath, output] of Object.entries(metafile.outputs)) {
|
|
106
|
+
if (!outputPath.endsWith('.js'))
|
|
107
|
+
continue;
|
|
108
|
+
for (const [inputPath, input] of Object.entries(output.inputs)) {
|
|
109
|
+
if (input.bytesInOutput > 0)
|
|
110
|
+
inputs.add(inputPath);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return [...inputs];
|
|
114
|
+
}
|
|
115
|
+
export function packageFromJson(kind, packageRoot, packageJson, overrides) {
|
|
116
|
+
return {
|
|
117
|
+
kind,
|
|
118
|
+
name: packageJson.name ?? path.basename(packageRoot),
|
|
119
|
+
version: packageJson.version,
|
|
120
|
+
declaredLicense: declaredLicenseOf(packageJson, overrides),
|
|
121
|
+
repositoryUrl: normalizeRepositoryUrl(packageJson),
|
|
122
|
+
packageRoot,
|
|
123
|
+
contributingPaths: new Set(),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export async function collectInstalledPackages(nodeModulesDir, overrides) {
|
|
127
|
+
const packages = [];
|
|
128
|
+
async function scan(currentNodeModulesDir) {
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = await readdir(currentNodeModulesDir, { withFileTypes: true });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
137
|
+
if (entry.name === '.bin' || entry.isSymbolicLink())
|
|
138
|
+
continue;
|
|
139
|
+
if (entry.name.startsWith('@')) {
|
|
140
|
+
if (!entry.isDirectory())
|
|
141
|
+
continue;
|
|
142
|
+
await scanScopedPackages(path.join(currentNodeModulesDir, entry.name));
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!entry.isDirectory())
|
|
146
|
+
continue;
|
|
147
|
+
await scanPackage(path.join(currentNodeModulesDir, entry.name));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function scanScopedPackages(scopeDir) {
|
|
151
|
+
const entries = await readdir(scopeDir, { withFileTypes: true });
|
|
152
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
153
|
+
if (entry.isDirectory() && !entry.isSymbolicLink())
|
|
154
|
+
await scanPackage(path.join(scopeDir, entry.name));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function scanPackage(packageRoot) {
|
|
158
|
+
try {
|
|
159
|
+
packages.push(packageFromJson('external', packageRoot, await readPackageJson(packageRoot), overrides));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
await scan(path.join(packageRoot, 'node_modules'));
|
|
165
|
+
}
|
|
166
|
+
await scan(nodeModulesDir);
|
|
167
|
+
return packages;
|
|
168
|
+
}
|
|
169
|
+
const MAX_LEGAL_FILE_SIZE = 1024 * 1024;
|
|
170
|
+
export function normalizeLegalText(content) {
|
|
171
|
+
return content.replace(/\r\n/g, '\n').trim();
|
|
172
|
+
}
|
|
173
|
+
function sha256(content) {
|
|
174
|
+
return createHash('sha256').update(content).digest('hex');
|
|
175
|
+
}
|
|
176
|
+
export function isPathInside(childPath, parentPath) {
|
|
177
|
+
const relativePath = path.relative(parentPath, childPath);
|
|
178
|
+
return (relativePath === '' ||
|
|
179
|
+
(!relativePath.startsWith(`..${path.sep}`) && relativePath !== '..' && !path.isAbsolute(relativePath)));
|
|
180
|
+
}
|
|
181
|
+
function legalRole(filename) {
|
|
182
|
+
if (/^(license|licence|copying)/i.test(filename))
|
|
183
|
+
return 'license';
|
|
184
|
+
// Where a package vendors code it did not write, this is where it carries those licenses
|
|
185
|
+
if (/^third[-_. ]?party/i.test(filename))
|
|
186
|
+
return 'license';
|
|
187
|
+
if (/^notice/i.test(filename))
|
|
188
|
+
return 'notice';
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
async function readLegalText(filePath, packageRoot, role) {
|
|
192
|
+
const linkStat = await lstat(filePath);
|
|
193
|
+
if (linkStat.isSymbolicLink())
|
|
194
|
+
return undefined;
|
|
195
|
+
const fileStat = await stat(filePath);
|
|
196
|
+
if (!fileStat.isFile() || fileStat.size > MAX_LEGAL_FILE_SIZE)
|
|
197
|
+
return undefined;
|
|
198
|
+
const rawContent = await readFile(filePath, 'utf8');
|
|
199
|
+
if (rawContent.includes('\0'))
|
|
200
|
+
return undefined;
|
|
201
|
+
const content = normalizeLegalText(rawContent);
|
|
202
|
+
if (!content)
|
|
203
|
+
return undefined;
|
|
204
|
+
return { role, filename: normalizeInventoryPath(filePath, packageRoot), content, sha256: sha256(content) };
|
|
205
|
+
}
|
|
206
|
+
function sourceLoader(filename) {
|
|
207
|
+
switch (path.extname(filename).toLowerCase()) {
|
|
208
|
+
case '.js':
|
|
209
|
+
case '.mjs':
|
|
210
|
+
case '.cjs':
|
|
211
|
+
return 'js';
|
|
212
|
+
case '.ts':
|
|
213
|
+
return 'ts';
|
|
214
|
+
case '.tsx':
|
|
215
|
+
return 'tsx';
|
|
216
|
+
default:
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export async function collectPackageLegalMaterial(packageInfo) {
|
|
221
|
+
const diagnostics = [];
|
|
222
|
+
const candidateFiles = new Map();
|
|
223
|
+
for (const entry of await readdir(packageInfo.packageRoot, { withFileTypes: true })) {
|
|
224
|
+
if (!entry.isFile())
|
|
225
|
+
continue;
|
|
226
|
+
const role = legalRole(entry.name);
|
|
227
|
+
if (role)
|
|
228
|
+
candidateFiles.set(entry.name, role);
|
|
229
|
+
}
|
|
230
|
+
const seeLicenseMatch = /^SEE LICENSE IN (.+)$/i.exec(packageInfo.declaredLicense ?? '');
|
|
231
|
+
if (seeLicenseMatch) {
|
|
232
|
+
const explicitPath = path.resolve(packageInfo.packageRoot, seeLicenseMatch[1]);
|
|
233
|
+
if (!isPathInside(explicitPath, packageInfo.packageRoot)) {
|
|
234
|
+
diagnostics.push(`Ignoring license file outside package root: ${seeLicenseMatch[1]}`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
candidateFiles.set(normalizeInventoryPath(explicitPath, packageInfo.packageRoot), 'license');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const legalTexts = [];
|
|
241
|
+
for (const [filename, role] of [...candidateFiles.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
242
|
+
try {
|
|
243
|
+
const legalText = await readLegalText(path.join(packageInfo.packageRoot, filename), packageInfo.packageRoot, role);
|
|
244
|
+
if (legalText)
|
|
245
|
+
legalTexts.push(legalText);
|
|
246
|
+
else
|
|
247
|
+
diagnostics.push(`Ignoring unreadable, binary, or oversized legal file: ${filename}`);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
diagnostics.push(`Ignoring unreadable legal file: ${filename}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!legalTexts.some((text) => text.role === 'license')) {
|
|
254
|
+
for (const sourcePath of [...packageInfo.contributingPaths].sort()) {
|
|
255
|
+
const loader = sourceLoader(sourcePath);
|
|
256
|
+
if (!loader) {
|
|
257
|
+
diagnostics.push(`Ignoring unsupported source file for legal comments: ${sourcePath}`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const source = await readFile(path.join(packageInfo.packageRoot, sourcePath), 'utf8');
|
|
262
|
+
const result = await esbuild.transform(source, { loader, legalComments: 'external' });
|
|
263
|
+
if (result.legalComments) {
|
|
264
|
+
const content = normalizeLegalText(result.legalComments);
|
|
265
|
+
if (content) {
|
|
266
|
+
legalTexts.push({
|
|
267
|
+
role: 'source-comment',
|
|
268
|
+
filename: sourcePath,
|
|
269
|
+
content,
|
|
270
|
+
sha256: sha256(content),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
diagnostics.push(`Ignoring unreadable source file for legal comments: ${sourcePath}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const uniqueTexts = new Map();
|
|
281
|
+
for (const legalText of legalTexts) {
|
|
282
|
+
const key = legalText.role === 'source-comment'
|
|
283
|
+
? `${legalText.role}:${legalText.sha256}`
|
|
284
|
+
: `${legalText.role}:${legalText.filename}:${legalText.sha256}`;
|
|
285
|
+
uniqueTexts.set(key, legalText);
|
|
286
|
+
}
|
|
287
|
+
return { package: { ...packageInfo, legalTexts: [...uniqueTexts.values()] }, diagnostics };
|
|
288
|
+
}
|
|
289
|
+
function comparePackages(a, b) {
|
|
290
|
+
if (a.kind === 'project' && b.kind !== 'project')
|
|
291
|
+
return -1;
|
|
292
|
+
if (a.kind !== 'project' && b.kind === 'project')
|
|
293
|
+
return 1;
|
|
294
|
+
return a.name.localeCompare(b.name) || (a.version ?? '').localeCompare(b.version ?? '');
|
|
295
|
+
}
|
|
296
|
+
function packageName(packageInfo) {
|
|
297
|
+
return packageInfo.version ? `${packageInfo.name}@${packageInfo.version}` : packageInfo.name;
|
|
298
|
+
}
|
|
299
|
+
function renderLegalFile(inventory, roles) {
|
|
300
|
+
const groupedTexts = new Map();
|
|
301
|
+
for (const packageInfo of inventory.packages) {
|
|
302
|
+
for (const text of packageInfo.legalTexts) {
|
|
303
|
+
if (!roles.includes(text.role))
|
|
304
|
+
continue;
|
|
305
|
+
const group = groupedTexts.get(text.sha256) ?? { text, packages: new Map() };
|
|
306
|
+
const packageKey = `${packageInfo.kind === 'project' ? 'project' : 'dependency'}:${packageName(packageInfo)}:${packageInfo.declaredLicense ?? ''}`;
|
|
307
|
+
group.packages.set(packageKey, packageInfo);
|
|
308
|
+
groupedTexts.set(text.sha256, group);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!groupedTexts.size)
|
|
312
|
+
return undefined;
|
|
313
|
+
const sections = [...groupedTexts.values()].sort((a, b) => comparePackages([...a.packages.values()].sort(comparePackages)[0], [...b.packages.values()].sort(comparePackages)[0]));
|
|
314
|
+
const separator = '-'.repeat(80);
|
|
315
|
+
const renderedSections = sections.map((section) => {
|
|
316
|
+
const packages = [...section.packages.values()].sort(comparePackages);
|
|
317
|
+
const packageList = packages
|
|
318
|
+
.map((packageInfo) => {
|
|
319
|
+
const label = `${packageName(packageInfo)} — ${packageInfo.declaredLicense ?? 'UNKNOWN'}`;
|
|
320
|
+
// Tells recipients where to obtain the source, which MPL-2.0 and the GPL family require
|
|
321
|
+
return packageInfo.repositoryUrl ? `${label} (${packageInfo.repositoryUrl})` : label;
|
|
322
|
+
})
|
|
323
|
+
.join(', ');
|
|
324
|
+
return `Packages: ${packageList}\n${separator}\n${normalizeLegalText(section.text.content)}`;
|
|
325
|
+
});
|
|
326
|
+
return `${renderedSections.join('\n\n')}\n`;
|
|
327
|
+
}
|
|
328
|
+
export function renderLicenseFile(inventory) {
|
|
329
|
+
return renderLegalFile(inventory, ['license', 'source-comment']) ?? '';
|
|
330
|
+
}
|
|
331
|
+
export function renderNoticeFile(inventory) {
|
|
332
|
+
return renderLegalFile(inventory, ['notice']);
|
|
333
|
+
}
|
|
334
|
+
export async function writeLegalArtifacts(outputDir, inventory) {
|
|
335
|
+
await writeFile(path.join(outputDir, 'LICENSE'), renderLicenseFile(inventory));
|
|
336
|
+
const notice = renderNoticeFile(inventory);
|
|
337
|
+
const noticePath = path.join(outputDir, 'NOTICE');
|
|
338
|
+
if (notice)
|
|
339
|
+
await writeFile(noticePath, notice);
|
|
340
|
+
else
|
|
341
|
+
await rm(noticePath, { force: true });
|
|
342
|
+
}
|
|
343
|
+
const kindPriority = {
|
|
344
|
+
project: 0,
|
|
345
|
+
bundled: 1,
|
|
346
|
+
external: 2,
|
|
347
|
+
};
|
|
348
|
+
export async function createLegalInventory(packages) {
|
|
349
|
+
const mergedPackages = new Map();
|
|
350
|
+
for (const packageInfo of packages) {
|
|
351
|
+
const existing = mergedPackages.get(packageInfo.packageRoot);
|
|
352
|
+
if (!existing) {
|
|
353
|
+
mergedPackages.set(packageInfo.packageRoot, {
|
|
354
|
+
...packageInfo,
|
|
355
|
+
contributingPaths: new Set(packageInfo.contributingPaths),
|
|
356
|
+
});
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
for (const sourcePath of packageInfo.contributingPaths)
|
|
360
|
+
existing.contributingPaths.add(sourcePath);
|
|
361
|
+
if (kindPriority[packageInfo.kind] < kindPriority[existing.kind])
|
|
362
|
+
existing.kind = packageInfo.kind;
|
|
363
|
+
}
|
|
364
|
+
const diagnostics = [];
|
|
365
|
+
const legalRecords = [];
|
|
366
|
+
for (const packageInfo of mergedPackages.values()) {
|
|
367
|
+
const material = await collectPackageLegalMaterial(packageInfo);
|
|
368
|
+
legalRecords.push(material.package);
|
|
369
|
+
diagnostics.push(...material.diagnostics);
|
|
370
|
+
}
|
|
371
|
+
return { packages: legalRecords, diagnostics };
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Attributes each file which contributed to a bundle to the package that owns it. Callers which bundle with esbuild
|
|
375
|
+
* pass a metafile to collectMetafilePackages instead; this is the entrypoint for every other bundler.
|
|
376
|
+
*/
|
|
377
|
+
export async function collectPackagesFromInputPaths(inputPaths, project, overrides) {
|
|
378
|
+
const projectPackageJson = await readPackageJson(project.packageRoot);
|
|
379
|
+
const projectLicense = project.distributionLicense ?? declaredLicenseOf(projectPackageJson, overrides);
|
|
380
|
+
const packages = new Map();
|
|
381
|
+
const diagnostics = [];
|
|
382
|
+
for (const inputPath of inputPaths) {
|
|
383
|
+
// Resolve ownership using the logical node_modules path. Realpath would turn linked dependencies into paths
|
|
384
|
+
// outside the project roots and incorrectly classify them as project code.
|
|
385
|
+
const installedRoot = await findInstalledPackageRoot(inputPath);
|
|
386
|
+
if (!installedRoot && !project.projectRoots.some((root) => isPathInside(inputPath, root))) {
|
|
387
|
+
diagnostics.push(`Ignoring bundle input outside the project: ${inputPath}`);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const packageRoot = installedRoot ?? project.packageRoot;
|
|
391
|
+
const kind = installedRoot ? 'bundled' : 'project';
|
|
392
|
+
const key = `${kind}:${packageRoot}`;
|
|
393
|
+
let packageInfo = packages.get(key);
|
|
394
|
+
if (!packageInfo) {
|
|
395
|
+
const packageJson = installedRoot ? await readPackageJson(installedRoot) : projectPackageJson;
|
|
396
|
+
packageInfo = {
|
|
397
|
+
kind,
|
|
398
|
+
name: (kind === 'project' ? project.name : undefined) ?? packageJson.name ?? path.basename(packageRoot),
|
|
399
|
+
version: packageJson.version,
|
|
400
|
+
declaredLicense: kind === 'project' ? asLicenseString(projectLicense) : declaredLicenseOf(packageJson, overrides),
|
|
401
|
+
sourceLicense: kind === 'project' ? declaredLicenseOf(projectPackageJson, overrides) : undefined,
|
|
402
|
+
repositoryUrl: normalizeRepositoryUrl(packageJson),
|
|
403
|
+
packageRoot,
|
|
404
|
+
contributingPaths: new Set(),
|
|
405
|
+
};
|
|
406
|
+
packages.set(key, packageInfo);
|
|
407
|
+
}
|
|
408
|
+
packageInfo.contributingPaths.add(normalizeInventoryPath(inputPath, packageRoot));
|
|
409
|
+
}
|
|
410
|
+
return { packages: [...packages.values()], diagnostics };
|
|
411
|
+
}
|
|
412
|
+
/** Attributes the inputs of an esbuild metafile to the packages that own them */
|
|
413
|
+
export async function collectMetafilePackages(metafile,
|
|
414
|
+
/** The directory metafile input paths are relative to, which is esbuild's absWorkingDir */
|
|
415
|
+
absWorkingDir, project, overrides) {
|
|
416
|
+
const inputPaths = [];
|
|
417
|
+
const diagnostics = [];
|
|
418
|
+
for (const input of getContributingInputs(metafile)) {
|
|
419
|
+
if (input.startsWith('<')) {
|
|
420
|
+
diagnostics.push(`Ignoring virtual esbuild input: ${input}`);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
inputPaths.push(path.resolve(absWorkingDir, input));
|
|
424
|
+
}
|
|
425
|
+
const collection = await collectPackagesFromInputPaths(inputPaths, project, overrides);
|
|
426
|
+
return { packages: collection.packages, diagnostics: [...diagnostics, ...collection.diagnostics] };
|
|
427
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { LegalInventory, LicenseOverrides, ShippedPackageLegalRecord } from './inventory.js';
|
|
2
|
+
export interface LicensePolicyIssue {
|
|
3
|
+
packageName: string;
|
|
4
|
+
packageVersion?: string;
|
|
5
|
+
packageKind: ShippedPackageLegalRecord['kind'];
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
type LicensePolicyOutput = Pick<NodeJS.WriteStream, 'write'>;
|
|
9
|
+
export declare class LicensePolicyError extends Error {
|
|
10
|
+
readonly issues: LicensePolicyIssue[];
|
|
11
|
+
constructor(issues: LicensePolicyIssue[]);
|
|
12
|
+
}
|
|
13
|
+
/** Licenses a project may be declared as, each allowing a different set of dependency licenses */
|
|
14
|
+
export type ProjectLicense = 'MIT' | 'GPL-2.0-only' | 'GPL-3.0-only';
|
|
15
|
+
export declare const SUPPORTED_PROJECT_LICENSES: ProjectLicense[];
|
|
16
|
+
/**
|
|
17
|
+
* The project-specific wording of the policy, so that the same engine can report issues in terms of whatever it is
|
|
18
|
+
* checking. Everything a message needs to say about how this project declares and ships its licenses lives here.
|
|
19
|
+
*/
|
|
20
|
+
export interface LicensePolicyDescriptor {
|
|
21
|
+
/** What the project is called in messages, such as "module" or "application" */
|
|
22
|
+
subject: string;
|
|
23
|
+
/** Where the license the built artifact ships under is declared, such as "companion/manifest.json" */
|
|
24
|
+
distributionLicenseLocation: string;
|
|
25
|
+
/**
|
|
26
|
+
* The license the project's own source must be declared as in package.json, or undefined when the project has no
|
|
27
|
+
* rule separating its source license from the license it distributes under.
|
|
28
|
+
*/
|
|
29
|
+
requiredSourceLicense: string | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* How to move a dependency out of the bundle and ship it alongside, or undefined when the project has no way to,
|
|
32
|
+
* in which case a license which is only acceptable as an external is reported as incompatible instead.
|
|
33
|
+
*/
|
|
34
|
+
externaliseAdvice: string | undefined;
|
|
35
|
+
/** Appended once after the issues, to point readers at where they can get help */
|
|
36
|
+
helpMessage: string;
|
|
37
|
+
}
|
|
38
|
+
/** Resolve the license the built artifact is distributed under, which selects the policy applied to its dependencies */
|
|
39
|
+
export declare function resolveProjectLicense(inventory: LegalInventory): {
|
|
40
|
+
declaration: string;
|
|
41
|
+
license: ProjectLicense | undefined;
|
|
42
|
+
};
|
|
43
|
+
export declare function createLicensePolicyIssues(inventory: LegalInventory, descriptor: LicensePolicyDescriptor, overrides: LicenseOverrides): LicensePolicyIssue[];
|
|
44
|
+
export declare function enforceLicensePolicy(inventory: LegalInventory, descriptor: LicensePolicyDescriptor, overrides: LicenseOverrides, options?: {
|
|
45
|
+
ignoreLicenseRules?: boolean;
|
|
46
|
+
stderr?: LicensePolicyOutput;
|
|
47
|
+
}): void;
|
|
48
|
+
export {};
|