@callstack/licenses 0.3.2 → 1.0.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.
Files changed (61) hide show
  1. package/README.md +4 -4
  2. package/build/constants/licenses.d.ts +4 -0
  3. package/build/constants/licenses.js +15 -15
  4. package/build/licenses/OrPolicy.d.ts +21 -0
  5. package/build/licenses/OrPolicy.js +16 -0
  6. package/build/licenses/index.d.ts +3 -0
  7. package/build/licenses/index.js +3 -0
  8. package/build/licenses/licenseAnalysis.d.ts +7 -8
  9. package/build/licenses/licenseAnalysis.js +15 -29
  10. package/build/licenses/licenseClassification.d.ts +52 -0
  11. package/build/licenses/licenseClassification.js +136 -0
  12. package/build/licenses/licenseExpression.d.ts +37 -0
  13. package/build/licenses/licenseExpression.js +166 -0
  14. package/build/node/common.d.ts +23 -2
  15. package/build/node/common.js +159 -56
  16. package/build/node/index.d.ts +1 -0
  17. package/build/node/index.js +1 -0
  18. package/build/node/mergeLicensesMappings.d.ts +8 -0
  19. package/build/node/mergeLicensesMappings.js +28 -0
  20. package/build/node/utils/aboutLibrariesLicenses.d.ts +11 -0
  21. package/build/node/utils/aboutLibrariesLicenses.js +58 -0
  22. package/build/node/utils/licenseFiles.d.ts +8 -0
  23. package/build/node/utils/licenseFiles.js +45 -0
  24. package/build/node/utils/licensePlistBody.d.ts +6 -0
  25. package/build/node/utils/licensePlistBody.js +20 -0
  26. package/build/node/utils/packageUtils.d.ts +14 -5
  27. package/build/node/utils/packageUtils.js +35 -23
  28. package/build/types/AboutLibrariesLikePackageInfo.d.ts +1 -1
  29. package/build/types/License.d.ts +14 -6
  30. package/build/types/LicenseAnalysisResult.d.ts +7 -0
  31. package/build/types/LicenseExpression.d.ts +39 -0
  32. package/build/types/LicenseExpression.js +2 -0
  33. package/build/types/LicenseFile.d.ts +8 -0
  34. package/build/types/LicenseFile.js +2 -0
  35. package/build/types/LicenseStats.d.ts +1 -1
  36. package/build/types/ScanDependenciesOptions.d.ts +12 -0
  37. package/build/types/ScanDependenciesOptions.js +2 -0
  38. package/build/types/index.d.ts +3 -0
  39. package/build/types/index.js +3 -0
  40. package/package.json +9 -6
  41. package/src/constants/licenses.ts +15 -15
  42. package/src/licenses/OrPolicy.ts +23 -0
  43. package/src/licenses/index.ts +3 -0
  44. package/src/licenses/licenseAnalysis.ts +19 -33
  45. package/src/licenses/licenseClassification.ts +173 -0
  46. package/src/licenses/licenseExpression.ts +200 -0
  47. package/src/node/common.ts +217 -66
  48. package/src/node/index.ts +1 -0
  49. package/src/node/mergeLicensesMappings.ts +32 -0
  50. package/src/node/utils/aboutLibrariesLicenses.ts +69 -0
  51. package/src/node/utils/licenseFiles.ts +50 -0
  52. package/src/node/utils/licensePlistBody.ts +18 -0
  53. package/src/node/utils/packageUtils.ts +37 -26
  54. package/src/types/AboutLibrariesLikePackageInfo.ts +1 -1
  55. package/src/types/License.ts +16 -7
  56. package/src/types/LicenseAnalysisResult.ts +8 -0
  57. package/src/types/LicenseExpression.ts +22 -0
  58. package/src/types/LicenseFile.ts +10 -0
  59. package/src/types/LicenseStats.ts +1 -1
  60. package/src/types/ScanDependenciesOptions.ts +12 -0
  61. package/src/types/index.ts +3 -0
@@ -0,0 +1,200 @@
1
+ import spdxExceptionIds from 'spdx-exceptions/index.json';
2
+ import parse from 'spdx-expression-parse';
3
+ import deprecatedSpdxLicenseIds from 'spdx-license-ids/deprecated.json';
4
+ import spdxLicenseIds from 'spdx-license-ids/index.json';
5
+
6
+ import type { LicenseExpression } from '../types';
7
+
8
+ const SPDX_LICENSE_IDS = new Set(spdxLicenseIds);
9
+ const DEPRECATED_SPDX_LICENSE_IDS = new Set(deprecatedSpdxLicenseIds);
10
+
11
+ const CANONICAL_ID_BY_LOWERCASE = new Map(
12
+ [...spdxLicenseIds, ...deprecatedSpdxLicenseIds, ...spdxExceptionIds].map((id) => [id.toLowerCase(), id]),
13
+ );
14
+
15
+ const SPDX_OPERATORS = new Set(['and', 'or', 'with']);
16
+
17
+ /**
18
+ * Unambiguous spellings that are not valid SPDX License Expressions, keyed by the lowercase, trimmed value.
19
+ * Ambiguous values (`BSD`, `GPL`, `LGPL`) are deliberately absent: guessing a variant or version changes the legal meaning.
20
+ */
21
+ const LICENSE_ALIASES = new Map([
22
+ ['apache 2.0', 'Apache-2.0'],
23
+ ['apache-2', 'Apache-2.0'],
24
+ ['apache license 2.0', 'Apache-2.0'],
25
+ ['mit/x11', 'MIT'],
26
+ ['mit license', 'MIT'],
27
+ ]);
28
+
29
+ /**
30
+ * Parses a Raw License into a License Expression, following the SPDX License Expression syntax.
31
+ *
32
+ * Never throws: a missing or unparseable value becomes an Unknown License that keeps the original value.
33
+ *
34
+ * @param rawLicense the license declaration as written in package.json, or `null` if absent
35
+ * @returns the parsed License Expression
36
+ * @example
37
+ * parseLicenseExpression('MIT OR Apache-2.0');
38
+ * // { kind: 'or', left: { kind: 'license', id: 'MIT', declaredId: 'MIT' }, right: { kind: 'license', id: 'Apache-2.0', declaredId: 'Apache-2.0' } }
39
+ */
40
+ export function parseLicenseExpression(rawLicense: string | null): LicenseExpression {
41
+ if (rawLicense === null) {
42
+ return { kind: 'unknown', raw: rawLicense };
43
+ }
44
+
45
+ const parsed =
46
+ tryParse(rawLicense) ??
47
+ tryParse(LICENSE_ALIASES.get(rawLicense.trim().toLowerCase())) ??
48
+ tryParse(fixIdentifierCase(rawLicense));
49
+
50
+ return parsed ?? { kind: 'unknown', raw: rawLicense };
51
+ }
52
+
53
+ function tryParse(rawLicense: string | undefined): LicenseExpression | undefined {
54
+ if (rawLicense === undefined) {
55
+ return undefined;
56
+ }
57
+
58
+ try {
59
+ return mapSpdxParsedValueToLicenseExpression(parse(rawLicense));
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Rewrites every identifier and operator to its canonical spelling (SPDX Annex B.2: identifiers are case-insensitive).
67
+ * Tokens that match no known identifier are left as written, so the re-parse rejects them.
68
+ */
69
+ function fixIdentifierCase(rawLicense: string): string {
70
+ return rawLicense.replace(/[^\s()]+/g, (token) => {
71
+ if (SPDX_OPERATORS.has(token.toLowerCase())) {
72
+ return token.toUpperCase();
73
+ }
74
+
75
+ const plus = token.endsWith('+') ? '+' : '';
76
+ const id = plus ? token.slice(0, -1) : token;
77
+ const canonicalId =
78
+ CANONICAL_ID_BY_LOWERCASE.get(id.toLowerCase()) ??
79
+ (/^licenseref-/i.test(id) ? `LicenseRef-${id.slice('LicenseRef-'.length)}` : id);
80
+
81
+ return `${canonicalId}${plus}`;
82
+ });
83
+ }
84
+
85
+ function mapSpdxParsedValueToLicenseExpression(spdxParsedValue: parse.Info): LicenseExpression {
86
+ if ('conjunction' in spdxParsedValue) {
87
+ return {
88
+ kind: spdxParsedValue.conjunction,
89
+ left: mapSpdxParsedValueToLicenseExpression(spdxParsedValue.left),
90
+ right: mapSpdxParsedValueToLicenseExpression(spdxParsedValue.right),
91
+ };
92
+ }
93
+
94
+ const { license: declaredId, plus, exception } = spdxParsedValue;
95
+
96
+ return {
97
+ kind: 'license',
98
+ id: normalizeLicenseId(declaredId, plus === true),
99
+ declaredId,
100
+ ...(plus && { plus: true }),
101
+ ...(exception && { exception }),
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Upgrades a declared License Identifier to its canonical form:
107
+ * - with `+`, to the `-or-later` identifier when one exists;
108
+ * - a deprecated identifier, to its `-only` identifier when one exists.
109
+ */
110
+ function normalizeLicenseId(declaredId: string, plus: boolean): string {
111
+ if (plus && SPDX_LICENSE_IDS.has(`${declaredId}-or-later`)) {
112
+ return `${declaredId}-or-later`;
113
+ }
114
+
115
+ if (!plus && DEPRECATED_SPDX_LICENSE_IDS.has(declaredId) && SPDX_LICENSE_IDS.has(`${declaredId}-only`)) {
116
+ return `${declaredId}-only`;
117
+ }
118
+
119
+ return declaredId;
120
+ }
121
+
122
+ /**
123
+ * Collects the License Identifiers mentioned in a License Expression, regardless of how they are combined.
124
+ *
125
+ * @param expression the License Expression to flatten
126
+ * @returns the License Identifiers in order of first appearance, without duplicates; empty for an Unknown License
127
+ * @example
128
+ * collectLicenseIds(parseLicenseExpression('(MIT OR ISC) AND (MIT OR Apache-2.0)'));
129
+ * // ['MIT', 'ISC', 'Apache-2.0']
130
+ */
131
+ export function collectLicenseIds(expression: LicenseExpression): string[] {
132
+ switch (expression.kind) {
133
+ case 'license':
134
+ return [expression.id];
135
+ case 'and':
136
+ case 'or':
137
+ return [...new Set([...collectLicenseIds(expression.left), ...collectLicenseIds(expression.right)])];
138
+ case 'unknown':
139
+ return [];
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Renders a License Expression to a string, following the SPDX License Expression syntax.
145
+ *
146
+ * Adds only the parentheses required by SPDX precedence and by the right-nesting of operator chains,
147
+ * so that parsing the result gives back the same tree. An Unknown License renders as its original value
148
+ * (or `unknown` when absent) and is not expected to parse back to the same tree.
149
+ *
150
+ * @param expression the License Expression to render
151
+ * @returns the rendered License Expression
152
+ * @example
153
+ * renderLicenseExpression(parseLicenseExpression('(MIT OR Apache-2.0) AND ISC'));
154
+ * // '(MIT OR Apache-2.0) AND ISC'
155
+ */
156
+ export function renderLicenseExpression(expression: LicenseExpression): string {
157
+ switch (expression.kind) {
158
+ case 'unknown':
159
+ return expression.raw ?? 'unknown';
160
+ case 'or':
161
+ case 'and': {
162
+ const left = renderOperand(expression.left, expression.kind, 'left');
163
+ const right = renderOperand(expression.right, expression.kind, 'right');
164
+
165
+ return `${left} ${expression.kind.toUpperCase()} ${right}`;
166
+ }
167
+
168
+ case 'license':
169
+ return renderLicenseLeaf(expression);
170
+ }
171
+ }
172
+
173
+ function renderLicenseLeaf(leaf: Extract<LicenseExpression, { kind: 'license' }>): string {
174
+ // `+` is already absorbed into an `-or-later` identifier
175
+ const plus = leaf.plus && !leaf.id.endsWith('-or-later') ? '+' : '';
176
+ const exception = leaf.exception ? ` WITH ${leaf.exception}` : '';
177
+
178
+ return `${leaf.id}${plus}${exception}`;
179
+ }
180
+
181
+ function renderOperand(
182
+ operand: LicenseExpression,
183
+ parentKind: 'or' | 'and',
184
+ operandPosition: 'left' | 'right',
185
+ ): string {
186
+ const rendered = renderLicenseExpression(operand);
187
+
188
+ return shouldAddParenthesis(operand, parentKind, operandPosition) ? `(${rendered})` : rendered;
189
+ }
190
+
191
+ function shouldAddParenthesis(
192
+ childExpression: LicenseExpression,
193
+ parentKind: 'or' | 'and',
194
+ childPosition: 'left' | 'right',
195
+ ): boolean {
196
+ return (
197
+ (parentKind === 'and' && childExpression.kind === 'or') ||
198
+ (childPosition === 'left' && parentKind === childExpression.kind)
199
+ );
200
+ }
@@ -1,25 +1,150 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
- import glob from 'glob';
4
+ import { glob } from 'glob';
5
5
 
6
+ import { collectLicenseIds, parseLicenseExpression } from '../licenses';
6
7
  import type {
7
8
  AboutLibrariesLibraryJsonPayload,
8
9
  AboutLibrariesLicenseJsonPayload,
9
10
  AboutLibrariesLikePackageInfo,
10
11
  AggregatedLicensesMapping,
11
12
  DependencyType,
13
+ License,
12
14
  LicensePlistPayload,
13
15
  ParentPackageInfo,
16
+ ScanDependenciesOptions,
14
17
  ScanPackageCallContext,
15
18
  ScanPackageOptionsFactory,
16
19
  } from '../types';
17
20
  import { YamlUtils } from '../utils';
18
21
 
22
+ import { mergeLicensesMappings } from './mergeLicensesMappings';
19
23
  import { PackageUtils } from './utils';
20
24
 
21
25
  type InternalScanGroupSpecifier = { packages: [depName: string, depVersion: string][]; dependencyType: DependencyType };
22
26
 
27
+ /**
28
+ * Finds the root directory of the package that a directory belongs to - the closest directory (itself or a parent)
29
+ * containing a `package.json` with a `name` field. Nested `package.json` files without a name
30
+ * (e.g. `lib/commonjs/package.json` containing only `{ "type": "commonjs" }`) are skipped.
31
+ *
32
+ * Works for packages installed in `node_modules` as well as for workspace / linked packages,
33
+ * which are referenced by their real path (outside `node_modules`)
34
+ *
35
+ * @param startDir Absolute path to the directory to start the search from, e.g. the directory of a module file
36
+ * @returns Path to the package root directory or `null` if the directory does not belong to any named package
37
+ */
38
+ export function findPackageRoot(startDir: string): string | null {
39
+ let dir = startDir;
40
+
41
+ // `path.dirname` of the filesystem root returns the root itself, which ends the loop
42
+ while (path.dirname(dir) !== dir) {
43
+ if (hasNamedPackageJson(dir)) {
44
+ return dir;
45
+ }
46
+
47
+ dir = path.dirname(dir);
48
+ }
49
+
50
+ return hasNamedPackageJson(dir) ? dir : null;
51
+ }
52
+
53
+ /**
54
+ * Checks whether a directory contains a valid `package.json` with a `name` field
55
+ *
56
+ * @param dir Path to the directory to check
57
+ */
58
+ function hasNamedPackageJson(dir: string): boolean {
59
+ const packageJsonPath = path.join(dir, 'package.json');
60
+
61
+ if (!fs.existsSync(packageJsonPath)) {
62
+ return false;
63
+ }
64
+
65
+ try {
66
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, { encoding: 'utf-8' }));
67
+
68
+ return Boolean(packageJson.name);
69
+ } catch {
70
+ // invalid package.json - treat it as not a package root
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Collects license information for a given list of package directories, e.g. the packages found in the Metro dependency graph.
77
+ * Unlike {@link scanDependencies}, it does not scan dependencies of the packages - the list is expected to be complete.
78
+ *
79
+ * @param packageRoots Paths to the root directories of the packages (directories containing `package.json`)
80
+ * @returns Aggregated licenses object containing the given packages and their license information
81
+ */
82
+ export function scanPackageRoots(packageRoots: string[]): AggregatedLicensesMapping {
83
+ const result: AggregatedLicensesMapping = {};
84
+
85
+ for (const packageRoot of packageRoots) {
86
+ const packageJsonPath = path.join(packageRoot, 'package.json');
87
+
88
+ if (!fs.existsSync(packageJsonPath)) {
89
+ console.warn(`[react-native-legal] skipping ${packageRoot} could not find package.json`);
90
+ continue;
91
+ }
92
+
93
+ try {
94
+ const packageJson = require(path.resolve(packageJsonPath));
95
+
96
+ if (!packageJson.name || packageJson.private === true) {
97
+ continue;
98
+ }
99
+
100
+ const licenseInfo = readPackageLicenseInfo(packageJsonPath);
101
+
102
+ result[`${licenseInfo.name}@${licenseInfo.version}`] = {
103
+ ...licenseInfo,
104
+ dependencyType: 'dependency',
105
+ requiredVersion: licenseInfo.version,
106
+ parentPackages: [],
107
+ };
108
+ } catch (error) {
109
+ console.warn(`[react-native-legal] could not process package.json in ${packageRoot}`);
110
+ }
111
+ }
112
+
113
+ return result;
114
+ }
115
+
116
+ /**
117
+ * Reads license information of a single package, based only on its own files
118
+ * (package.json and LICENSE file), without any context of how the package was found
119
+ *
120
+ * @param packageJsonPath Path to the package.json file of the package
121
+ */
122
+ function readPackageLicenseInfo(
123
+ packageJsonPath: string,
124
+ ): Pick<
125
+ License,
126
+ 'name' | 'author' | 'description' | 'rawLicense' | 'license' | 'licenseIds' | 'licenseFiles' | 'url' | 'version'
127
+ > {
128
+ const packageJson = require(path.resolve(packageJsonPath));
129
+
130
+ const rawLicense = PackageUtils.parseLicenseField(packageJson) ?? null;
131
+ const license = parseLicenseExpression(rawLicense);
132
+ const licenseIds = collectLicenseIds(license);
133
+ const licenseFiles = PackageUtils.readLicenseFiles(path.dirname(packageJsonPath), licenseIds);
134
+
135
+ return {
136
+ name: packageJson.name,
137
+ author: PackageUtils.parseAuthorField(packageJson),
138
+ description: packageJson.description,
139
+ rawLicense,
140
+ license,
141
+ licenseIds,
142
+ licenseFiles,
143
+ url: PackageUtils.parseRepositoryFieldToUrl(packageJson),
144
+ version: packageJson.version,
145
+ };
146
+ }
147
+
23
148
  /**
24
149
  * Scans a single package and its dependencies for license information
25
150
  *
@@ -43,7 +168,8 @@ function scanPackage(
43
168
  dependencyType,
44
169
  parentPackageRequiredVersion,
45
170
  parentPackageResolvedVersion,
46
- }: ScanPackageCallContext,
171
+ projectRoot,
172
+ }: InternalScanPackageCallContext,
47
173
  ) {
48
174
  const requiredVersionPackageKey = `${packageName}@${requiredVersion}`;
49
175
 
@@ -62,7 +188,7 @@ function scanPackage(
62
188
  processedPackages.add(requiredVersionPackageKey);
63
189
 
64
190
  try {
65
- const localPackageJsonPath = PackageUtils.getPackageJsonPath(packageName, parentPackageRoot);
191
+ const localPackageJsonPath = PackageUtils.getPackageJsonPath(packageName, parentPackageRoot, projectRoot);
66
192
 
67
193
  if (!localPackageJsonPath) {
68
194
  // do not warn if the package is an optional dependency, it's normal it may not be installed
@@ -76,14 +202,6 @@ function scanPackage(
76
202
  const localPackageJson = require(path.resolve(localPackageJsonPath));
77
203
 
78
204
  if (localPackageJson.private !== true) {
79
- const licenseFiles = glob.sync('LICEN{S,C}E{.md,}', {
80
- cwd: path.dirname(localPackageJsonPath),
81
- absolute: true,
82
- nocase: true,
83
- nodir: true,
84
- ignore: '**/{__tests__,__fixtures__,__mocks__}/**',
85
- });
86
-
87
205
  const resolvedVersionPackageKey = `${packageName}@${localPackageJson.version}`;
88
206
 
89
207
  let parentPackageInfo: ParentPackageInfo | undefined;
@@ -102,14 +220,8 @@ function scanPackage(
102
220
  ];
103
221
  } else {
104
222
  result[resolvedVersionPackageKey] = {
223
+ ...readPackageLicenseInfo(localPackageJsonPath),
105
224
  name: packageName,
106
- author: PackageUtils.parseAuthorField(localPackageJson),
107
- content: licenseFiles?.[0] ? fs.readFileSync(licenseFiles[0], { encoding: 'utf-8' }) : undefined,
108
- file: licenseFiles?.[0] ? licenseFiles[0] : undefined,
109
- description: localPackageJson.description,
110
- type: PackageUtils.parseLicenseField(localPackageJson),
111
- url: PackageUtils.parseRepositoryFieldToUrl(localPackageJson),
112
- version: localPackageJson.version,
113
225
  requiredVersion,
114
226
  parentPackages: parentPackageInfo ? [parentPackageInfo] : [],
115
227
  parentPackageRequiredVersion,
@@ -159,6 +271,7 @@ function scanPackage(
159
271
  parentPackageName: packageName,
160
272
  parentPackageRequiredVersion: requiredVersion,
161
273
  parentPackageResolvedVersion: localPackageJson.version,
274
+ projectRoot,
162
275
  });
163
276
  }
164
277
  }
@@ -169,22 +282,63 @@ function scanPackage(
169
282
 
170
283
  type DependencyMapping = Record<string, string>;
171
284
  type MaybeDependencyMapping = DependencyMapping | undefined;
285
+ type InternalScanPackageCallContext = ScanPackageCallContext & { projectRoot: string };
172
286
 
173
287
  /**
174
288
  * Scans `package.json` and searches for all packages under `dependencies` field. Supports monorepo projects.
175
289
  *
176
290
  * @param appPackageJsonPath Path to the `package.json` file of the application
177
291
  * @param scanOptionsFactory Factory function to create scan options for dependencies; defaults to {@link PackageUtils.legacyDefaultScanPackageOptionsFactory}
292
+ * @param options Additional options, see {@link ScanDependenciesOptions}
178
293
  * @returns Aggregated licenses object containing all scanned dependencies and their license information
179
294
  */
180
295
  export function scanDependencies(
181
296
  appPackageJsonPath: string,
182
297
  scanOptionsFactory: ScanPackageOptionsFactory = PackageUtils.legacyDefaultScanPackageOptionsFactory,
298
+ options: ScanDependenciesOptions = {},
299
+ ): AggregatedLicensesMapping {
300
+ const { additionalProjectRoots = [] } = options;
301
+
302
+ if (
303
+ !Array.isArray(additionalProjectRoots) ||
304
+ additionalProjectRoots.some(
305
+ (additionalProjectRoot) => typeof additionalProjectRoot !== 'string' || !additionalProjectRoot,
306
+ )
307
+ ) {
308
+ throw new TypeError('[react-native-legal] additionalProjectRoots must be an array of non-empty paths');
309
+ }
310
+
311
+ const appRoot = fs.realpathSync(path.dirname(path.resolve(appPackageJsonPath)));
312
+ const additionalPackageJsonPaths = additionalProjectRoots.map((additionalProjectRoot) => {
313
+ const packageJsonPath = path.join(path.resolve(appRoot, additionalProjectRoot), 'package.json');
314
+
315
+ if (!fs.existsSync(packageJsonPath)) {
316
+ throw new Error(
317
+ `[react-native-legal] additional project root "${additionalProjectRoot}" is not a directory with a package.json (${packageJsonPath} not found)`,
318
+ );
319
+ }
320
+
321
+ return packageJsonPath;
322
+ });
323
+
324
+ return mergeLicensesMappings(
325
+ scanProjectDependencies(appPackageJsonPath, scanOptionsFactory),
326
+ ...additionalPackageJsonPaths.map((packageJsonPath) =>
327
+ scanProjectDependencies(packageJsonPath, scanOptionsFactory),
328
+ ),
329
+ );
330
+ }
331
+
332
+ function scanProjectDependencies(
333
+ projectPackageJsonPath: string,
334
+ scanOptionsFactory: ScanPackageOptionsFactory,
183
335
  ): AggregatedLicensesMapping {
184
- const appPackageJson = require(path.resolve(appPackageJsonPath));
185
- const dependencies: MaybeDependencyMapping = appPackageJson.dependencies;
186
- const devDependencies: MaybeDependencyMapping = appPackageJson.devDependencies;
187
- const optionalDependencies: MaybeDependencyMapping = appPackageJson.optionalDependencies;
336
+ const projectPackageJson = require(path.resolve(projectPackageJsonPath));
337
+ // real path, so that `node_modules` directories of the project's parent directories are found even if the project is reached through a symlink
338
+ const projectRoot = fs.realpathSync(path.dirname(path.resolve(projectPackageJsonPath)));
339
+ const dependencies: MaybeDependencyMapping = projectPackageJson.dependencies;
340
+ const devDependencies: MaybeDependencyMapping = projectPackageJson.devDependencies;
341
+ const optionalDependencies: MaybeDependencyMapping = projectPackageJson.optionalDependencies;
188
342
  const result: AggregatedLicensesMapping = {};
189
343
  const processedPackages = new Set<string>();
190
344
 
@@ -208,6 +362,7 @@ export function scanDependencies(
208
362
  for (const [depName, depVersion] of packages) {
209
363
  scanPackage(depName, depVersion as string, processedPackages, result, scanOptionsFactory, {
210
364
  dependencyType,
365
+ projectRoot,
211
366
  });
212
367
  }
213
368
  }
@@ -236,13 +391,17 @@ export function generateLicensePlistNPMOutput(licenses: AggregatedLicensesMappin
236
391
  renames[normalizedPackageNameWithVersion] = license.name;
237
392
  }
238
393
 
239
- const relativeLicenseFile = license.file ? path.relative(iosProjectPath, license.file) : undefined;
394
+ // LicensePlist holds one `file`/`body` per package: a single license file is referenced directly,
395
+ // otherwise every license text is concatenated into the body under a per-license heading
396
+ const singleLicenseFile = license.licenseFiles.length === 1 ? license.licenseFiles[0].file : undefined;
240
397
 
241
398
  return {
242
399
  name: normalizedPackageNameWithVersion,
243
400
  version: license.version,
244
401
  ...(license.url && { source: license.url }),
245
- ...(license.file ? { file: relativeLicenseFile } : { body: license.content ?? license.type ?? 'UNKNOWN' }),
402
+ ...(singleLicenseFile
403
+ ? { file: path.relative(iosProjectPath, singleLicenseFile) }
404
+ : { body: PackageUtils.buildLicensePlistBody(license) }),
246
405
  } as LicensePlistPayload;
247
406
  });
248
407
 
@@ -341,45 +500,34 @@ export function writeLicensePlistNPMOutput(
341
500
  * @see {@link writeAboutLibrariesNPMOutput}
342
501
  */
343
502
  export function generateAboutLibrariesNPMOutput(licenses: AggregatedLicensesMapping): AboutLibrariesLikePackageInfo[] {
344
- return Object.entries(licenses)
345
- .map(([packageKey, license]) => {
346
- return {
347
- artifactVersion: license.version,
348
- content: license.content ?? '',
349
- description: license.description ?? '',
350
- developers: [{ name: license.author ?? '', organisationUrl: '' }],
351
- licenses: [PackageUtils.prepareAboutLibrariesLicenseField(license)],
352
- name: license.name,
353
- tag: '',
354
- type: license.type,
355
- uniqueId: PackageUtils.normalizePackageName(packageKey),
356
- website: license.url,
357
- };
358
- })
359
- .map((jsonPayload) => {
360
- const libraryJsonPayload: AboutLibrariesLibraryJsonPayload = {
361
- artifactVersion: jsonPayload.artifactVersion,
362
- description: jsonPayload.description,
363
- developers: jsonPayload.developers,
364
- licenses: jsonPayload.licenses,
365
- name: jsonPayload.name,
366
- tag: jsonPayload.tag,
367
- uniqueId: jsonPayload.uniqueId,
368
- website: jsonPayload.website,
369
- };
370
- const licenseJsonPayload: AboutLibrariesLicenseJsonPayload = {
371
- content: jsonPayload.content,
372
- hash: jsonPayload.licenses[0],
373
- name: jsonPayload.type ?? '',
374
- url: '',
375
- };
503
+ return Object.entries(licenses).map(([packageKey, license]) => {
504
+ const uniqueId = PackageUtils.normalizePackageName(packageKey);
505
+ const licenseJsonPayloads: AboutLibrariesLicenseJsonPayload[] = PackageUtils.prepareAboutLibrariesLicenses(
506
+ license,
507
+ ).map(({ name, content }) => ({
508
+ content,
509
+ hash: PackageUtils.prepareAboutLibrariesLicenseField(name, content),
510
+ name,
511
+ url: '',
512
+ }));
513
+
514
+ const libraryJsonPayload: AboutLibrariesLibraryJsonPayload = {
515
+ artifactVersion: license.version,
516
+ description: license.description ?? '',
517
+ developers: [{ name: license.author ?? '', organisationUrl: '' }],
518
+ licenses: licenseJsonPayloads.map(({ hash }) => hash),
519
+ name: license.name,
520
+ tag: '',
521
+ uniqueId,
522
+ website: license.url,
523
+ };
376
524
 
377
- return {
378
- normalizedPackageNameWithVersion: jsonPayload.uniqueId,
379
- libraryJsonPayload,
380
- licenseJsonPayload,
381
- };
382
- });
525
+ return {
526
+ normalizedPackageNameWithVersion: uniqueId,
527
+ libraryJsonPayload,
528
+ licenseJsonPayloads,
529
+ };
530
+ });
383
531
  }
384
532
 
385
533
  /**
@@ -427,17 +575,20 @@ export function writeAboutLibrariesNPMOutput(
427
575
  aboutLibrariesLikeOutput = generateAboutLibrariesNPMOutput(licenses);
428
576
  }
429
577
 
430
- aboutLibrariesLikeOutput.forEach(({ normalizedPackageNameWithVersion, libraryJsonPayload, licenseJsonPayload }) => {
578
+ aboutLibrariesLikeOutput.forEach(({ normalizedPackageNameWithVersion, libraryJsonPayload, licenseJsonPayloads }) => {
431
579
  const libraryJsonFilePath = path.join(
432
580
  aboutLibrariesConfigLibrariesDirPath,
433
581
  `${normalizedPackageNameWithVersion}.json`,
434
582
  );
435
- const licenseJsonFilePath = path.join(aboutLibrariesConfigLicensesDirPath, `${licenseJsonPayload.hash}.json`);
436
583
 
437
584
  fs.writeFileSync(libraryJsonFilePath, JSON.stringify(libraryJsonPayload));
438
585
 
439
- if (!fs.existsSync(licenseJsonFilePath)) {
440
- fs.writeFileSync(licenseJsonFilePath, JSON.stringify(licenseJsonPayload));
586
+ for (const licenseJsonPayload of licenseJsonPayloads) {
587
+ const licenseJsonFilePath = path.join(aboutLibrariesConfigLicensesDirPath, `${licenseJsonPayload.hash}.json`);
588
+
589
+ if (!fs.existsSync(licenseJsonFilePath)) {
590
+ fs.writeFileSync(licenseJsonFilePath, JSON.stringify(licenseJsonPayload));
591
+ }
441
592
  }
442
593
  });
443
594
  }
package/src/node/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './common';
2
+ export * from './mergeLicensesMappings';
2
3
  export * from './utils';
3
4
  export * from '../';
@@ -0,0 +1,32 @@
1
+ import type { AggregatedLicensesMapping } from '../types';
2
+
3
+ import { MiscUtils } from './utils';
4
+
5
+ /**
6
+ * Combines licenses mappings, e.g. from scans of several project roots, into one.
7
+ *
8
+ * @param mappings Licenses mappings to combine; a package version present in several of them keeps the entry from the first one, with the parent packages of all of them
9
+ * @returns A new licenses mapping that shares no objects with the mappings passed in
10
+ */
11
+ export function mergeLicensesMappings(...mappings: AggregatedLicensesMapping[]): AggregatedLicensesMapping {
12
+ const result: AggregatedLicensesMapping = {};
13
+
14
+ for (const mapping of mappings) {
15
+ for (const [packageKey, license] of Object.entries(mapping)) {
16
+ const existingLicense = result[packageKey];
17
+
18
+ if (!existingLicense) {
19
+ result[packageKey] = structuredClone({ ...license, parentPackages: license.parentPackages ?? [] });
20
+ continue;
21
+ }
22
+
23
+ for (const parentPackage of license.parentPackages ?? []) {
24
+ if (!MiscUtils.arrayIncludesObject(existingLicense.parentPackages, parentPackage)) {
25
+ existingLicense.parentPackages.push(structuredClone(parentPackage));
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ return result;
32
+ }