@companion-module/tools 2.7.2 → 2.8.0
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 +8 -0
- package/README.md +29 -3
- package/package.json +5 -1
- package/scripts/build-connection.js +12 -2
- package/scripts/build-surface.js +12 -2
- package/scripts/check-connection.js +16 -5
- package/scripts/check-surface.js +19 -5
- package/scripts/lib/build-util.js +21 -1
- package/scripts/lib/check-util.js +23 -0
- package/scripts/lib/cli-options.js +8 -0
- package/scripts/lib/known-package-licenses.js +93 -0
- package/scripts/lib/license-util.js +681 -0
- package/scripts/lib/license-warning-util.js +539 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import { lstat, readdir, realpath, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { knownPackageLicense } from './known-package-licenses.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {'project' | 'bundled' | 'external'} ShippedPackageKind
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} ShippedPackage
|
|
13
|
+
* @property {ShippedPackageKind} kind
|
|
14
|
+
* @property {string} name
|
|
15
|
+
* @property {string | undefined} [version]
|
|
16
|
+
* @property {string | undefined} [declaredLicense]
|
|
17
|
+
* @property {string | undefined} [sourceLicense] Only for the project, the package.json license of its own source, which declaredLicense distributes it under
|
|
18
|
+
* @property {string | undefined} [repositoryUrl] Where recipients can obtain the source of this package, as required by MPL-2.0 and the GPL family
|
|
19
|
+
* @property {string} packageRoot
|
|
20
|
+
* @property {Set<string>} contributingPaths
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} ShippedPackageCollection
|
|
25
|
+
* @property {ShippedPackage[]} packages
|
|
26
|
+
* @property {string[]} diagnostics
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} LegalText
|
|
31
|
+
* @property {'license' | 'notice' | 'source-comment'} role
|
|
32
|
+
* @property {string} filename
|
|
33
|
+
* @property {string} content
|
|
34
|
+
* @property {string} sha256
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {ShippedPackage & { legalTexts: LegalText[] }} ShippedPackageLegalRecord
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {object} PackageLegalMaterial
|
|
43
|
+
* @property {ShippedPackageLegalRecord} package
|
|
44
|
+
* @property {string[]} diagnostics
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} LegalInventory
|
|
49
|
+
* @property {ShippedPackageLegalRecord[]} packages
|
|
50
|
+
* @property {string[]} diagnostics
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {object} PackageJson
|
|
55
|
+
* @property {string} [name]
|
|
56
|
+
* @property {string} [version]
|
|
57
|
+
* @property {unknown} [license]
|
|
58
|
+
* @property {unknown} [licenses]
|
|
59
|
+
* @property {unknown} [repository]
|
|
60
|
+
* @property {unknown} [homepage]
|
|
61
|
+
* @property {unknown} [dependencies]
|
|
62
|
+
* @property {unknown} [optionalDependencies]
|
|
63
|
+
* @property {unknown} [peerDependencies]
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/** @type {Record<string, string>} */
|
|
67
|
+
const SHORTHAND_REPOSITORY_HOSTS = {
|
|
68
|
+
github: 'https://github.com/',
|
|
69
|
+
gitlab: 'https://gitlab.com/',
|
|
70
|
+
bitbucket: 'https://bitbucket.org/',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {PackageJson} packageJson
|
|
75
|
+
* @returns {string | undefined}
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeRepositoryUrl(packageJson) {
|
|
78
|
+
const repository = packageJson.repository
|
|
79
|
+
const declared =
|
|
80
|
+
typeof repository === 'string'
|
|
81
|
+
? repository
|
|
82
|
+
: repository && typeof repository === 'object' && typeof repository.url === 'string'
|
|
83
|
+
? repository.url
|
|
84
|
+
: undefined
|
|
85
|
+
|
|
86
|
+
let url = declared?.trim() ?? ''
|
|
87
|
+
if (url) {
|
|
88
|
+
// npm shorthands, either "user/repo" or "<host>:user/repo"
|
|
89
|
+
if (/^[\w.-]+\/[\w.-]+$/.test(url)) url = `${SHORTHAND_REPOSITORY_HOSTS.github}${url}`
|
|
90
|
+
const shorthand = /^([a-z]+):([\w.-]+\/[\w.-]+)$/.exec(url)
|
|
91
|
+
if (shorthand && SHORTHAND_REPOSITORY_HOSTS[shorthand[1]]) {
|
|
92
|
+
url = `${SHORTHAND_REPOSITORY_HOSTS[shorthand[1]]}${shorthand[2]}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
url = url.replace(/^git\+/, '').replace(/^(?:git|ssh):\/\/(?:git@)?/, 'https://')
|
|
96
|
+
const scpLike = /^git@([^:]+):(.+)$/.exec(url)
|
|
97
|
+
if (scpLike) url = `https://${scpLike[1]}/${scpLike[2]}`
|
|
98
|
+
url = url.replace(/\.git$/, '')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!/^https?:\/\//.test(url)) url = typeof packageJson.homepage === 'string' ? packageJson.homepage.trim() : ''
|
|
102
|
+
return /^https?:\/\//.test(url) ? url : undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {string} inputPath
|
|
107
|
+
* @param {string} ownerRoot
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function normalizeInventoryPath(inputPath, ownerRoot) {
|
|
111
|
+
const relativePath = path.relative(ownerRoot, inputPath)
|
|
112
|
+
if (relativePath === '' || relativePath === '.') return '.'
|
|
113
|
+
if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
|
|
114
|
+
return '<outside package root>'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return relativePath.split(path.sep).join('/')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} packageRoot
|
|
122
|
+
* @returns {Promise<PackageJson>}
|
|
123
|
+
*/
|
|
124
|
+
async function readPackageJson(packageRoot) {
|
|
125
|
+
return JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {unknown} license
|
|
130
|
+
* @returns {string | undefined}
|
|
131
|
+
*/
|
|
132
|
+
function asLicenseString(license) {
|
|
133
|
+
if (typeof license === 'string') return license
|
|
134
|
+
// npm deprecated { type, url } and arrays of it long ago, but older packages are still published with them
|
|
135
|
+
if (Array.isArray(license)) {
|
|
136
|
+
const choices = license.map(asLicenseString).filter((choice) => choice !== undefined)
|
|
137
|
+
if (!choices.length) return undefined
|
|
138
|
+
return choices.length === 1 ? choices[0] : `(${choices.join(' OR ')})` // An array offers a choice of licenses
|
|
139
|
+
}
|
|
140
|
+
if (license && typeof license === 'object') return asLicenseString(license.type)
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Reads a package's license, preferring the current field over the deprecated plural one
|
|
146
|
+
* @param {PackageJson} packageJson
|
|
147
|
+
* @returns {string | undefined}
|
|
148
|
+
*/
|
|
149
|
+
function declaredLicenseOf(packageJson) {
|
|
150
|
+
return (
|
|
151
|
+
asLicenseString(packageJson.license) ??
|
|
152
|
+
asLicenseString(packageJson.licenses) ??
|
|
153
|
+
// Only for packages declaring nothing at all, so this can never override what a package says about itself
|
|
154
|
+
knownPackageLicense(packageJson.name, packageJson.version)
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {string} moduleDir
|
|
160
|
+
* @returns {Promise<unknown>}
|
|
161
|
+
*/
|
|
162
|
+
async function readManifestLicense(moduleDir) {
|
|
163
|
+
try {
|
|
164
|
+
const manifest = JSON.parse(await readFile(path.join(moduleDir, 'companion', 'manifest.json'), 'utf8'))
|
|
165
|
+
return manifest.license
|
|
166
|
+
} catch {
|
|
167
|
+
return undefined // Modules without a readable manifest fall back to the package.json license
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve where node would load `name` from when it is required by code in `fromDir`, by walking up the node_modules
|
|
173
|
+
* chain. require.resolve is not usable here, it fails for packages which declare no main and for packages whose
|
|
174
|
+
* exports map does not expose a bare entry. The logical path is returned rather than the realpath, so that linked
|
|
175
|
+
* dependencies keep the identity node_modules gave them.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} fromDir
|
|
178
|
+
* @param {string} name
|
|
179
|
+
* @returns {Promise<string | undefined>}
|
|
180
|
+
*/
|
|
181
|
+
async function resolvePackageDir(fromDir, name) {
|
|
182
|
+
let currentDir = fromDir
|
|
183
|
+
for (;;) {
|
|
184
|
+
// A node_modules directory never contains a nested node_modules of its own
|
|
185
|
+
if (path.basename(currentDir) !== 'node_modules') {
|
|
186
|
+
const candidate = path.join(currentDir, 'node_modules', name)
|
|
187
|
+
try {
|
|
188
|
+
await readPackageJson(candidate)
|
|
189
|
+
return candidate
|
|
190
|
+
} catch {
|
|
191
|
+
// Not installed at this level, keep walking up
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const parentDir = path.dirname(currentDir)
|
|
196
|
+
if (parentDir === currentDir) return undefined
|
|
197
|
+
currentDir = parentDir
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The dependency names a package pulls in at runtime. devDependencies are deliberately absent, npm and yarn never
|
|
203
|
+
* install them transitively and nothing bundles them, so they are not part of what ships.
|
|
204
|
+
*
|
|
205
|
+
* @param {PackageJson} packageJson
|
|
206
|
+
* @returns {{ name: string, optional: boolean }[]}
|
|
207
|
+
*/
|
|
208
|
+
function dependencyNames(packageJson) {
|
|
209
|
+
/** @type {{ name: string, optional: boolean }[]} */
|
|
210
|
+
const names = []
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {unknown} value
|
|
214
|
+
* @param {boolean} optional
|
|
215
|
+
*/
|
|
216
|
+
const record = (value, optional) => {
|
|
217
|
+
if (!value || typeof value !== 'object') return
|
|
218
|
+
for (const name of Object.keys(value)) names.push({ name, optional })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
record(packageJson.dependencies, false)
|
|
222
|
+
// Optional and peer dependencies only ship when they actually resolved on disk, so a missing one is not a problem
|
|
223
|
+
record(packageJson.optionalDependencies, true)
|
|
224
|
+
record(packageJson.peerDependencies, true)
|
|
225
|
+
return names
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {ShippedPackageKind} kind
|
|
230
|
+
* @param {string} packageRoot
|
|
231
|
+
* @param {PackageJson} packageJson
|
|
232
|
+
* @returns {ShippedPackage}
|
|
233
|
+
*/
|
|
234
|
+
function packageFromJson(kind, packageRoot, packageJson) {
|
|
235
|
+
return {
|
|
236
|
+
kind,
|
|
237
|
+
name: packageJson.name ?? path.basename(packageRoot),
|
|
238
|
+
version: packageJson.version,
|
|
239
|
+
declaredLicense: declaredLicenseOf(packageJson),
|
|
240
|
+
repositoryUrl: normalizeRepositoryUrl(packageJson),
|
|
241
|
+
packageRoot,
|
|
242
|
+
contributingPaths: new Set(),
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {string} nodeModulesDir
|
|
248
|
+
* @returns {Promise<ShippedPackage[]>}
|
|
249
|
+
*/
|
|
250
|
+
export async function collectInstalledPackages(nodeModulesDir) {
|
|
251
|
+
/** @type {ShippedPackage[]} */
|
|
252
|
+
const packages = []
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @param {string} currentNodeModulesDir
|
|
256
|
+
* @returns {Promise<void>}
|
|
257
|
+
*/
|
|
258
|
+
async function scan(currentNodeModulesDir) {
|
|
259
|
+
let entries
|
|
260
|
+
try {
|
|
261
|
+
entries = await readdir(currentNodeModulesDir, { withFileTypes: true })
|
|
262
|
+
} catch {
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
267
|
+
if (entry.name === '.bin' || entry.isSymbolicLink()) continue
|
|
268
|
+
if (entry.name.startsWith('@')) {
|
|
269
|
+
if (!entry.isDirectory()) continue
|
|
270
|
+
await scanScopedPackages(path.join(currentNodeModulesDir, entry.name))
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
if (!entry.isDirectory()) continue
|
|
274
|
+
await scanPackage(path.join(currentNodeModulesDir, entry.name))
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* @param {string} scopeDir
|
|
280
|
+
* @returns {Promise<void>}
|
|
281
|
+
*/
|
|
282
|
+
async function scanScopedPackages(scopeDir) {
|
|
283
|
+
const entries = await readdir(scopeDir, { withFileTypes: true })
|
|
284
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
285
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) await scanPackage(path.join(scopeDir, entry.name))
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param {string} packageRoot
|
|
291
|
+
* @returns {Promise<void>}
|
|
292
|
+
*/
|
|
293
|
+
async function scanPackage(packageRoot) {
|
|
294
|
+
try {
|
|
295
|
+
packages.push(packageFromJson('external', packageRoot, await readPackageJson(packageRoot)))
|
|
296
|
+
} catch {
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
await scan(path.join(packageRoot, 'node_modules'))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
await scan(nodeModulesDir)
|
|
303
|
+
return packages
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const MAX_LEGAL_FILE_SIZE = 1024 * 1024
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* @param {string} content
|
|
310
|
+
* @returns {string}
|
|
311
|
+
*/
|
|
312
|
+
export function normalizeLegalText(content) {
|
|
313
|
+
return content.replace(/\r\n/g, '\n').trim()
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* @param {string} content
|
|
318
|
+
* @returns {string}
|
|
319
|
+
*/
|
|
320
|
+
function sha256(content) {
|
|
321
|
+
return createHash('sha256').update(content).digest('hex')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @param {string} childPath
|
|
326
|
+
* @param {string} parentPath
|
|
327
|
+
* @returns {boolean}
|
|
328
|
+
*/
|
|
329
|
+
function isPathInside(childPath, parentPath) {
|
|
330
|
+
const relativePath = path.relative(parentPath, childPath)
|
|
331
|
+
return (
|
|
332
|
+
relativePath === '' ||
|
|
333
|
+
(!relativePath.startsWith(`..${path.sep}`) && relativePath !== '..' && !path.isAbsolute(relativePath))
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* @param {string} filename
|
|
339
|
+
* @returns {LegalText['role'] | undefined}
|
|
340
|
+
*/
|
|
341
|
+
function legalRole(filename) {
|
|
342
|
+
if (/^(license|licence|copying)/i.test(filename)) return 'license'
|
|
343
|
+
if (/^notice/i.test(filename)) return 'notice'
|
|
344
|
+
return undefined
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* @param {string} filePath
|
|
349
|
+
* @param {string} packageRoot
|
|
350
|
+
* @param {LegalText['role']} role
|
|
351
|
+
* @returns {Promise<LegalText | undefined>}
|
|
352
|
+
*/
|
|
353
|
+
async function readLegalText(filePath, packageRoot, role) {
|
|
354
|
+
const linkStat = await lstat(filePath)
|
|
355
|
+
if (linkStat.isSymbolicLink()) return undefined
|
|
356
|
+
const fileStat = await stat(filePath)
|
|
357
|
+
if (!fileStat.isFile() || fileStat.size > MAX_LEGAL_FILE_SIZE) return undefined
|
|
358
|
+
const rawContent = await readFile(filePath, 'utf8')
|
|
359
|
+
if (rawContent.includes('\0')) return undefined
|
|
360
|
+
const content = normalizeLegalText(rawContent)
|
|
361
|
+
if (!content) return undefined
|
|
362
|
+
return { role, filename: normalizeInventoryPath(filePath, packageRoot), content, sha256: sha256(content) }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* @param {ShippedPackage} packageInfo
|
|
367
|
+
* @returns {Promise<PackageLegalMaterial>}
|
|
368
|
+
*/
|
|
369
|
+
export async function collectPackageLegalMaterial(packageInfo) {
|
|
370
|
+
/** @type {string[]} */
|
|
371
|
+
const diagnostics = []
|
|
372
|
+
/** @type {Map<string, LegalText['role']>} */
|
|
373
|
+
const candidateFiles = new Map()
|
|
374
|
+
for (const entry of await readdir(packageInfo.packageRoot, { withFileTypes: true })) {
|
|
375
|
+
if (!entry.isFile()) continue
|
|
376
|
+
const role = legalRole(entry.name)
|
|
377
|
+
if (role) candidateFiles.set(entry.name, role)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const seeLicenseMatch = /^SEE LICENSE IN (.+)$/i.exec(packageInfo.declaredLicense ?? '')
|
|
381
|
+
if (seeLicenseMatch) {
|
|
382
|
+
const explicitPath = path.resolve(packageInfo.packageRoot, seeLicenseMatch[1])
|
|
383
|
+
if (!isPathInside(explicitPath, packageInfo.packageRoot)) {
|
|
384
|
+
diagnostics.push(`Ignoring license file outside package root: ${seeLicenseMatch[1]}`)
|
|
385
|
+
} else {
|
|
386
|
+
candidateFiles.set(normalizeInventoryPath(explicitPath, packageInfo.packageRoot), 'license')
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** @type {LegalText[]} */
|
|
391
|
+
const legalTexts = []
|
|
392
|
+
for (const [filename, role] of [...candidateFiles.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
393
|
+
try {
|
|
394
|
+
const legalText = await readLegalText(path.join(packageInfo.packageRoot, filename), packageInfo.packageRoot, role)
|
|
395
|
+
if (legalText) legalTexts.push(legalText)
|
|
396
|
+
else diagnostics.push(`Ignoring unreadable, binary, or oversized legal file: ${filename}`)
|
|
397
|
+
} catch {
|
|
398
|
+
diagnostics.push(`Ignoring unreadable legal file: ${filename}`)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Where a package ships no license file at all, the esbuild build scrapes the `/*! ... */` banners out of the
|
|
403
|
+
// sources it bundled. Neither esbuild nor the set of bundled files is available here, so report it instead.
|
|
404
|
+
if (!legalTexts.some((text) => text.role === 'license')) {
|
|
405
|
+
diagnostics.push(
|
|
406
|
+
`No license file found in package: ${packageInfo.name}${packageInfo.version ? `@${packageInfo.version}` : ''}`,
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** @type {Map<string, LegalText>} */
|
|
411
|
+
const uniqueTexts = new Map()
|
|
412
|
+
for (const legalText of legalTexts) {
|
|
413
|
+
const key =
|
|
414
|
+
legalText.role === 'source-comment'
|
|
415
|
+
? `${legalText.role}:${legalText.sha256}`
|
|
416
|
+
: `${legalText.role}:${legalText.filename}:${legalText.sha256}`
|
|
417
|
+
uniqueTexts.set(key, legalText)
|
|
418
|
+
}
|
|
419
|
+
return { package: { ...packageInfo, legalTexts: [...uniqueTexts.values()] }, diagnostics }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* @param {ShippedPackageLegalRecord} a
|
|
424
|
+
* @param {ShippedPackageLegalRecord} b
|
|
425
|
+
* @returns {number}
|
|
426
|
+
*/
|
|
427
|
+
function comparePackages(a, b) {
|
|
428
|
+
if (a.kind === 'project' && b.kind !== 'project') return -1
|
|
429
|
+
if (a.kind !== 'project' && b.kind === 'project') return 1
|
|
430
|
+
return a.name.localeCompare(b.name) || (a.version ?? '').localeCompare(b.version ?? '')
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* @param {ShippedPackageLegalRecord} packageInfo
|
|
435
|
+
* @returns {string}
|
|
436
|
+
*/
|
|
437
|
+
function packageName(packageInfo) {
|
|
438
|
+
return packageInfo.version ? `${packageInfo.name}@${packageInfo.version}` : packageInfo.name
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* @param {LegalInventory} inventory
|
|
443
|
+
* @param {LegalText['role'][]} roles
|
|
444
|
+
* @returns {string | undefined}
|
|
445
|
+
*/
|
|
446
|
+
function renderLegalFile(inventory, roles) {
|
|
447
|
+
/** @type {Map<string, { text: LegalText, packages: Map<string, ShippedPackageLegalRecord> }>} */
|
|
448
|
+
const groupedTexts = new Map()
|
|
449
|
+
for (const packageInfo of inventory.packages) {
|
|
450
|
+
for (const text of packageInfo.legalTexts) {
|
|
451
|
+
if (!roles.includes(text.role)) continue
|
|
452
|
+
const group = groupedTexts.get(text.sha256) ?? { text, packages: new Map() }
|
|
453
|
+
const packageKey = `${packageInfo.kind === 'project' ? 'project' : 'dependency'}:${packageName(packageInfo)}:${packageInfo.declaredLicense ?? ''}`
|
|
454
|
+
group.packages.set(packageKey, packageInfo)
|
|
455
|
+
groupedTexts.set(text.sha256, group)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (!groupedTexts.size) return undefined
|
|
459
|
+
|
|
460
|
+
const sections = [...groupedTexts.values()].sort((a, b) =>
|
|
461
|
+
comparePackages(
|
|
462
|
+
[...a.packages.values()].sort(comparePackages)[0],
|
|
463
|
+
[...b.packages.values()].sort(comparePackages)[0],
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
const separator = '-'.repeat(80)
|
|
467
|
+
const renderedSections = sections.map((section) => {
|
|
468
|
+
const packages = [...section.packages.values()].sort(comparePackages)
|
|
469
|
+
const packageList = packages
|
|
470
|
+
.map((packageInfo) => {
|
|
471
|
+
const label = `${packageName(packageInfo)} — ${packageInfo.declaredLicense ?? 'UNKNOWN'}`
|
|
472
|
+
// Tells recipients where to obtain the source, which MPL-2.0 and the GPL family require
|
|
473
|
+
return packageInfo.repositoryUrl ? `${label} (${packageInfo.repositoryUrl})` : label
|
|
474
|
+
})
|
|
475
|
+
.join(', ')
|
|
476
|
+
return `Packages: ${packageList}\n${separator}\n${normalizeLegalText(section.text.content)}`
|
|
477
|
+
})
|
|
478
|
+
return `${renderedSections.join('\n\n')}\n`
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* @param {LegalInventory} inventory
|
|
483
|
+
* @returns {string}
|
|
484
|
+
*/
|
|
485
|
+
export function renderLicenseFile(inventory) {
|
|
486
|
+
return renderLegalFile(inventory, ['license', 'source-comment']) ?? ''
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* @param {LegalInventory} inventory
|
|
491
|
+
* @returns {string | undefined}
|
|
492
|
+
*/
|
|
493
|
+
export function renderNoticeFile(inventory) {
|
|
494
|
+
return renderLegalFile(inventory, ['notice'])
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* @param {string} outputDir
|
|
499
|
+
* @param {LegalInventory} inventory
|
|
500
|
+
* @returns {Promise<void>}
|
|
501
|
+
*/
|
|
502
|
+
export async function writeLegalArtifacts(outputDir, inventory) {
|
|
503
|
+
await writeFile(path.join(outputDir, 'LICENSE'), renderLicenseFile(inventory))
|
|
504
|
+
const notice = renderNoticeFile(inventory)
|
|
505
|
+
const noticePath = path.join(outputDir, 'NOTICE')
|
|
506
|
+
if (notice) await writeFile(noticePath, notice)
|
|
507
|
+
else await rm(noticePath, { force: true })
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** @type {Record<ShippedPackageKind, number>} */
|
|
511
|
+
const kindPriority = {
|
|
512
|
+
project: 0,
|
|
513
|
+
bundled: 1,
|
|
514
|
+
external: 2,
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* @param {ShippedPackage[]} packages
|
|
519
|
+
* @returns {Promise<LegalInventory>}
|
|
520
|
+
*/
|
|
521
|
+
export async function createLegalInventory(packages) {
|
|
522
|
+
/** @type {Map<string, ShippedPackage>} */
|
|
523
|
+
const mergedPackages = new Map()
|
|
524
|
+
for (const packageInfo of packages) {
|
|
525
|
+
const existing = mergedPackages.get(packageInfo.packageRoot)
|
|
526
|
+
if (!existing) {
|
|
527
|
+
mergedPackages.set(packageInfo.packageRoot, {
|
|
528
|
+
...packageInfo,
|
|
529
|
+
contributingPaths: new Set(packageInfo.contributingPaths),
|
|
530
|
+
})
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
for (const sourcePath of packageInfo.contributingPaths) existing.contributingPaths.add(sourcePath)
|
|
534
|
+
if (kindPriority[packageInfo.kind] < kindPriority[existing.kind]) existing.kind = packageInfo.kind
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** @type {string[]} */
|
|
538
|
+
const diagnostics = []
|
|
539
|
+
/** @type {ShippedPackageLegalRecord[]} */
|
|
540
|
+
const legalRecords = []
|
|
541
|
+
for (const packageInfo of mergedPackages.values()) {
|
|
542
|
+
const material = await collectPackageLegalMaterial(packageInfo)
|
|
543
|
+
legalRecords.push(material.package)
|
|
544
|
+
diagnostics.push(...material.diagnostics)
|
|
545
|
+
}
|
|
546
|
+
return { packages: legalRecords, diagnostics }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The names a module's build-config.cjs declares as externals. Only the object form is read, matching what the build
|
|
551
|
+
* itself installs into the package as dependencies.
|
|
552
|
+
*
|
|
553
|
+
* @param {string} moduleDir
|
|
554
|
+
* @returns {string[]}
|
|
555
|
+
*/
|
|
556
|
+
export function readExternalNames(moduleDir) {
|
|
557
|
+
let buildConfig
|
|
558
|
+
try {
|
|
559
|
+
buildConfig = createRequire(path.join(moduleDir, 'package.json'))(path.join(moduleDir, 'build-config.cjs'))
|
|
560
|
+
} catch {
|
|
561
|
+
return [] // Modules without a build-config.cjs bundle everything
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const externals = Array.isArray(buildConfig.externals)
|
|
565
|
+
? buildConfig.externals
|
|
566
|
+
: buildConfig.externals
|
|
567
|
+
? [buildConfig.externals]
|
|
568
|
+
: []
|
|
569
|
+
const names = []
|
|
570
|
+
for (const group of externals) {
|
|
571
|
+
if (group && typeof group === 'object') names.push(...Object.keys(group))
|
|
572
|
+
}
|
|
573
|
+
return names
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* @param {string} moduleDir
|
|
578
|
+
* @returns {Promise<LegalInventory>}
|
|
579
|
+
*/
|
|
580
|
+
export async function analyzeShippedLegalInventory(moduleDir) {
|
|
581
|
+
const shippedPackages = await collectProductionPackages(moduleDir, readExternalNames(moduleDir))
|
|
582
|
+
const inventory = await createLegalInventory(shippedPackages.packages)
|
|
583
|
+
return { ...inventory, diagnostics: [...shippedPackages.diagnostics, ...inventory.diagnostics] }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The set of packages which ship inside a built module. The esbuild build reads this out of the bundler metafile, so
|
|
588
|
+
* it sees exactly the files which made it into the bundle. webpack exposes no equivalent to a detached `npx webpack`,
|
|
589
|
+
* so the production dependency tree is walked instead. That is a superset of what actually gets bundled, anything
|
|
590
|
+
* tree-shaken away is still reported.
|
|
591
|
+
*
|
|
592
|
+
* @param {string} moduleDir
|
|
593
|
+
* @param {string[]} [externalNames] Names the build treats as externals, which ship beside the bundle rather than in it
|
|
594
|
+
* @returns {Promise<ShippedPackageCollection>}
|
|
595
|
+
*/
|
|
596
|
+
export async function collectProductionPackages(moduleDir, externalNames = []) {
|
|
597
|
+
const resolvedModuleDir = await realpath(moduleDir)
|
|
598
|
+
const projectPackageJson = await readPackageJson(resolvedModuleDir)
|
|
599
|
+
/** @type {ShippedPackage[]} */
|
|
600
|
+
const packages = []
|
|
601
|
+
/** @type {string[]} */
|
|
602
|
+
const diagnostics = []
|
|
603
|
+
|
|
604
|
+
// The manifest license is what the packaged module is distributed as, which is what its dependencies must fit.
|
|
605
|
+
// package.json only licenses the module's own source, so it is a fallback for modules not declaring the other.
|
|
606
|
+
const projectLicense = (await readManifestLicense(resolvedModuleDir)) ?? declaredLicenseOf(projectPackageJson)
|
|
607
|
+
|
|
608
|
+
packages.push({
|
|
609
|
+
kind: 'project',
|
|
610
|
+
name: projectPackageJson.name ?? path.basename(resolvedModuleDir),
|
|
611
|
+
version: projectPackageJson.version,
|
|
612
|
+
declaredLicense: asLicenseString(projectLicense),
|
|
613
|
+
sourceLicense: declaredLicenseOf(projectPackageJson),
|
|
614
|
+
repositoryUrl: normalizeRepositoryUrl(projectPackageJson),
|
|
615
|
+
packageRoot: resolvedModuleDir,
|
|
616
|
+
contributingPaths: new Set(),
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
const externalNameSet = new Set(externalNames)
|
|
620
|
+
const visited = new Set([resolvedModuleDir])
|
|
621
|
+
/** @type {{ dir: string, packageJson: PackageJson }[]} */
|
|
622
|
+
const deferredExternals = []
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* @param {ShippedPackageKind} kind
|
|
626
|
+
* @param {{ dir: string, packageJson: PackageJson }[]} roots
|
|
627
|
+
* @param {boolean} stopAtExternals
|
|
628
|
+
* @returns {Promise<void>}
|
|
629
|
+
*/
|
|
630
|
+
async function walk(kind, roots, stopAtExternals) {
|
|
631
|
+
let queue = roots
|
|
632
|
+
while (queue.length) {
|
|
633
|
+
/** @type {{ dir: string, packageJson: PackageJson }[]} */
|
|
634
|
+
const nextQueue = []
|
|
635
|
+
for (const entry of queue) {
|
|
636
|
+
for (const dependency of dependencyNames(entry.packageJson)) {
|
|
637
|
+
const packageRoot = await resolvePackageDir(entry.dir, dependency.name)
|
|
638
|
+
if (!packageRoot) {
|
|
639
|
+
if (!dependency.optional) {
|
|
640
|
+
diagnostics.push(
|
|
641
|
+
`Ignoring dependency which is not installed: ${dependency.name} (required by ${entry.packageJson.name ?? entry.dir})`,
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
continue
|
|
645
|
+
}
|
|
646
|
+
if (visited.has(packageRoot)) continue
|
|
647
|
+
|
|
648
|
+
const packageJson = await readPackageJson(packageRoot)
|
|
649
|
+
// The bundler externalises a name wherever it is required, so an external and everything below it
|
|
650
|
+
// is installed beside the bundle rather than built into it. Deferred rather than taken now, as a
|
|
651
|
+
// package an external depends on may still be reachable by a path which does bundle it.
|
|
652
|
+
if (stopAtExternals && externalNameSet.has(dependency.name)) {
|
|
653
|
+
deferredExternals.push({ dir: packageRoot, packageJson })
|
|
654
|
+
continue
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
visited.add(packageRoot)
|
|
658
|
+
packages.push(packageFromJson(kind, packageRoot, packageJson))
|
|
659
|
+
nextQueue.push({ dir: packageRoot, packageJson })
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
queue = nextQueue
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// What the bundler builds into main.js, stopping wherever an external takes over
|
|
667
|
+
await walk('bundled', [{ dir: resolvedModuleDir, packageJson: projectPackageJson }], true)
|
|
668
|
+
|
|
669
|
+
/** @type {{ dir: string, packageJson: PackageJson }[]} */
|
|
670
|
+
const externalRoots = []
|
|
671
|
+
for (const root of deferredExternals) {
|
|
672
|
+
if (visited.has(root.dir)) continue // Already reached by a path which bundles it, which is the stricter answer
|
|
673
|
+
visited.add(root.dir)
|
|
674
|
+
packages.push(packageFromJson('external', root.dir, root.packageJson))
|
|
675
|
+
externalRoots.push(root)
|
|
676
|
+
}
|
|
677
|
+
// The externals and their own dependencies, which are installed alongside the module and loaded at runtime
|
|
678
|
+
await walk('external', externalRoots, false)
|
|
679
|
+
|
|
680
|
+
return { packages, diagnostics }
|
|
681
|
+
}
|