@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
@@ -3,40 +3,56 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.legacyDefaultScanPackageOptionsFactory = void 0;
6
+ exports.legacyDefaultScanPackageOptionsFactory = exports.readLicenseFiles = exports.buildLicensePlistBody = exports.prepareAboutLibrariesLicenses = exports.prepareAboutLibrariesLicenseField = void 0;
7
7
  exports.getPackageJsonPath = getPackageJsonPath;
8
8
  exports.resolvePackageJsonFromEntry = resolvePackageJsonFromEntry;
9
9
  exports.findPackageRoot = findPackageRoot;
10
10
  exports.normalizePackageName = normalizePackageName;
11
- exports.prepareAboutLibrariesLicenseField = prepareAboutLibrariesLicenseField;
12
11
  exports.parseAuthorField = parseAuthorField;
13
12
  exports.parseLicenseField = parseLicenseField;
14
13
  exports.parseRepositoryFieldToUrl = parseRepositoryFieldToUrl;
15
14
  const fs_1 = __importDefault(require("fs"));
16
15
  const path_1 = __importDefault(require("path"));
17
16
  const repositoryUtils_1 = require("../../utils/repositoryUtils");
18
- const miscUtils_1 = require("./miscUtils");
19
- function getPackageJsonPath(dependency, root) {
17
+ var aboutLibrariesLicenses_1 = require("./aboutLibrariesLicenses");
18
+ Object.defineProperty(exports, "prepareAboutLibrariesLicenseField", { enumerable: true, get: function () { return aboutLibrariesLicenses_1.prepareAboutLibrariesLicenseField; } });
19
+ Object.defineProperty(exports, "prepareAboutLibrariesLicenses", { enumerable: true, get: function () { return aboutLibrariesLicenses_1.prepareAboutLibrariesLicenses; } });
20
+ var licensePlistBody_1 = require("./licensePlistBody");
21
+ Object.defineProperty(exports, "buildLicensePlistBody", { enumerable: true, get: function () { return licensePlistBody_1.buildLicensePlistBody; } });
22
+ var licenseFiles_1 = require("./licenseFiles");
23
+ Object.defineProperty(exports, "readLicenseFiles", { enumerable: true, get: function () { return licenseFiles_1.readLicenseFiles; } });
24
+ function getPackageJsonPath(dependency, root, projectRoot = process.cwd()) {
20
25
  const rootsToSearch = [
21
26
  ...(root ? [root] : []), // provided for purpose of nested node_modules resolution inside a package dir, in a subdirectory inside the root node_modules dir
22
- process.cwd(), // fallback - root node_modules directory home
27
+ projectRoot, // fallback - root node_modules directory home
23
28
  ]; // in order of priority (left-to-right)
24
29
  try {
25
30
  return require.resolve(`${dependency}/package.json`, { paths: rootsToSearch });
26
31
  }
27
32
  catch (error) {
28
33
  for (const root of rootsToSearch) {
29
- const pkgJsonInNodeModules = path_1.default.join(root, 'node_modules', dependency, 'package.json');
30
- if (fs_1.default.existsSync(pkgJsonInNodeModules)) {
34
+ const pkgJsonInNodeModules = findPackageJsonInNodeModules(dependency, root);
35
+ if (pkgJsonInNodeModules) {
31
36
  return pkgJsonInNodeModules;
32
37
  }
33
38
  }
34
- return resolvePackageJsonFromEntry(dependency); // final fallback
39
+ return resolvePackageJsonFromEntry(dependency, rootsToSearch); // final fallback
35
40
  }
36
41
  }
37
- function resolvePackageJsonFromEntry(dependency) {
42
+ function findPackageJsonInNodeModules(dependency, startDir) {
43
+ for (let currentDir = startDir;; currentDir = path_1.default.dirname(currentDir)) {
44
+ if (path_1.default.basename(currentDir) !== 'node_modules') {
45
+ const pkgJsonInNodeModules = path_1.default.join(currentDir, 'node_modules', dependency, 'package.json');
46
+ if (fs_1.default.existsSync(pkgJsonInNodeModules))
47
+ return fs_1.default.realpathSync(pkgJsonInNodeModules);
48
+ }
49
+ if (currentDir === path_1.default.dirname(currentDir))
50
+ return null;
51
+ }
52
+ }
53
+ function resolvePackageJsonFromEntry(dependency, paths) {
38
54
  try {
39
- const entryPath = require.resolve(dependency);
55
+ const entryPath = require.resolve(dependency, paths ? { paths } : undefined);
40
56
  const packageDir = findPackageRoot(entryPath);
41
57
  if (!packageDir)
42
58
  return null;
@@ -58,18 +74,6 @@ function findPackageRoot(entryPath) {
58
74
  function normalizePackageName(packageName) {
59
75
  return packageName.replace('/', '_');
60
76
  }
61
- function prepareAboutLibrariesLicenseField(license) {
62
- if (!license.type) {
63
- return '';
64
- }
65
- // The returned value is used as a filename under `android/config/licenses/`.
66
- // Legacy/compound SPDX expressions like `MIT/X11` or `(MIT OR Apache-2.0)` would
67
- // otherwise produce paths containing `/`, `(` or spaces, which causes the writer
68
- // to either fail with ENOENT (when a `/` is interpreted as a subdirectory) or
69
- // to produce names that are invalid on some filesystems.
70
- const sanitizedType = license.type.replace(/[^A-Za-z0-9._-]/g, '_');
71
- return `${sanitizedType}_${(0, miscUtils_1.sha512)(license.content ?? sanitizedType)}`;
72
- }
73
77
  function parseAuthorField(json) {
74
78
  if (typeof json.author === 'object' && typeof json.author.name === 'string') {
75
79
  return json.author.name;
@@ -78,13 +82,21 @@ function parseAuthorField(json) {
78
82
  return json.author;
79
83
  }
80
84
  }
85
+ /**
86
+ * Reads the Raw License from package.json: the `license` string, the legacy `license: { type }` object,
87
+ * or the legacy `licenses: [{ type }, …]` array, whose types are joined with ` OR `.
88
+ */
81
89
  function parseLicenseField(json) {
82
- if (typeof json.license === 'object' && typeof json.license.type === 'string') {
90
+ if (typeof json.license === 'object' && typeof json.license?.type === 'string') {
83
91
  return json.license.type;
84
92
  }
85
93
  if (typeof json.license === 'string') {
86
94
  return json.license;
87
95
  }
96
+ if (Array.isArray(json.licenses)) {
97
+ const types = json.licenses.flatMap((entry) => typeof entry?.type === 'string' && entry.type.trim() !== '' ? [entry.type] : []);
98
+ return types.length > 0 ? types.join(' OR ') : undefined;
99
+ }
88
100
  }
89
101
  function parseRepositoryFieldToUrl(json) {
90
102
  if (typeof json.repository === 'object' && typeof json.repository.url === 'string') {
@@ -3,5 +3,5 @@ import type { AboutLibrariesLicenseJsonPayload } from './AboutLibrariesLicenseJs
3
3
  export type AboutLibrariesLikePackageInfo = {
4
4
  normalizedPackageNameWithVersion: string;
5
5
  libraryJsonPayload: AboutLibrariesLibraryJsonPayload;
6
- licenseJsonPayload: AboutLibrariesLicenseJsonPayload;
6
+ licenseJsonPayloads: AboutLibrariesLicenseJsonPayload[];
7
7
  };
@@ -1,4 +1,6 @@
1
1
  import type { DependencyType } from './DependencyType';
2
+ import type { LicenseExpression } from './LicenseExpression';
3
+ import type { LicenseFile } from './LicenseFile';
2
4
  import type { ParentPackageInfo } from './ParentPackageInfo';
3
5
  import type { ScanPackageCallContext } from './ScanPackageCallContext';
4
6
  export type License = {
@@ -6,14 +8,20 @@ export type License = {
6
8
  name: string;
7
9
  /** Package author */
8
10
  author?: string;
9
- /** Package license contents */
10
- content?: string;
11
11
  /** Package description */
12
12
  description?: string;
13
- /** License file path */
14
- file?: string;
15
- /** License type */
16
- type?: string;
13
+ /**
14
+ * Raw License: the license declaration exactly as written in package.json;
15
+ * for the legacy `licenses: [{ type }]` array, the types joined with ` OR `;
16
+ * `null` if the package declares no license
17
+ */
18
+ rawLicense: string | null;
19
+ /** Parsed License Expression of {@link License.rawLicense} */
20
+ license: LicenseExpression;
21
+ /** Canonical SPDX License Identifiers found in {@link License.license}; empty for an Unknown License */
22
+ licenseIds: string[];
23
+ /** Every license file found in the package root, each linked to its License Identifier where unambiguous */
24
+ licenseFiles: LicenseFile[];
17
25
  /** Package repository URL */
18
26
  url?: string;
19
27
  /** The resolved version that was actually installed */
@@ -1,5 +1,12 @@
1
1
  import type { LicenseCategory } from '../licenses/LicenseCategory';
2
2
  import type { LicenseStats } from './LicenseStats';
3
3
  export interface LicenseAnalysisResult extends LicenseStats {
4
+ /**
5
+ * Mapping of package key (`<package-name>@<package-version>`) to the License Category of that package
6
+ */
4
7
  categorizedLicenses: Record<string, LicenseCategory>;
8
+ /**
9
+ * Mapping of rendered License Expression to its License Category; has the same keys as {@link LicenseStats.byLicense}
10
+ */
11
+ categoryByLicense: Record<string, LicenseCategory>;
5
12
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Structured meaning of a package's Raw License, following the SPDX License Expression
3
+ * syntax (SPDX spec v3.0.1, Annex B).
4
+ *
5
+ * The tree is binary: `A OR B OR C` is represented as `A OR (B OR C)`.
6
+ *
7
+ * @see https://spdx.github.io/spdx-spec/v3.0.1/annexes/spdx-license-expressions/
8
+ */
9
+ export type LicenseExpression =
10
+ /**
11
+ * A single license, identified by its SPDX License Identifier (e.g. `MIT`).
12
+ * - `id` is canonical: deprecated identifiers are upgraded and `+` is absorbed into `-or-later` when such an identifier exists
13
+ * - `declaredId` and `plus` record what was written
14
+ * - `exception` is the License Exception attached with `WITH`
15
+ */
16
+ {
17
+ kind: 'license';
18
+ id: string;
19
+ declaredId: string;
20
+ plus?: boolean;
21
+ exception?: string;
22
+ }
23
+ /** Dual License: the licensee may comply with either operand */
24
+ | {
25
+ kind: 'or';
26
+ left: LicenseExpression;
27
+ right: LicenseExpression;
28
+ }
29
+ /** Both operands apply and must be complied with simultaneously */
30
+ | {
31
+ kind: 'and';
32
+ left: LicenseExpression;
33
+ right: LicenseExpression;
34
+ }
35
+ /** Unknown License: missing, unparseable, or not a license grant; `raw` keeps the original value */
36
+ | {
37
+ kind: 'unknown';
38
+ raw: string | null;
39
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,8 @@
1
+ export type LicenseFile = {
2
+ /** License file path */
3
+ file: string;
4
+ /** License file contents */
5
+ content: string;
6
+ /** License Identifier the file was unambiguously linked to by its name, if any */
7
+ licenseId?: string;
8
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -9,7 +9,7 @@ export interface LicenseStats {
9
9
  */
10
10
  byCategory: Record<LicenseCategory, number>;
11
11
  /**
12
- * Mapping of license name to count of packages with that license
12
+ * Mapping of rendered License Expression to count of packages with that expression
13
13
  */
14
14
  byLicense: Record<string, number>;
15
15
  /**
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Additional options for scanning dependencies of a project
3
+ */
4
+ export type ScanDependenciesOptions = {
5
+ /**
6
+ * Paths to other project directories whose dependencies are scanned too,
7
+ * e.g. monorepo workspaces the app does not depend on;
8
+ * relative paths are resolved against the real directory (with symlinks resolved) of the scanned `package.json`;
9
+ * each of them must be a directory with a `package.json`, otherwise an error is thrown
10
+ */
11
+ additionalProjectRoots?: readonly string[];
12
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -5,8 +5,11 @@ export * from './AboutLibrariesLicenseJsonPayload';
5
5
  export * from './LicensePlistPayload';
6
6
  export * from './AboutLibrariesLikePackageInfo';
7
7
  export * from './ScanPackageOptions';
8
+ export * from './ScanDependenciesOptions';
8
9
  export * from './ScanPackageCallContext';
9
10
  export * from './DependencyType';
10
11
  export * from './ParentPackageInfo';
11
12
  export * from './LicenseAnalysisResult';
12
13
  export * from './LicenseStats';
14
+ export * from './LicenseExpression';
15
+ export * from './LicenseFile';
@@ -21,8 +21,11 @@ __exportStar(require("./AboutLibrariesLicenseJsonPayload"), exports);
21
21
  __exportStar(require("./LicensePlistPayload"), exports);
22
22
  __exportStar(require("./AboutLibrariesLikePackageInfo"), exports);
23
23
  __exportStar(require("./ScanPackageOptions"), exports);
24
+ __exportStar(require("./ScanDependenciesOptions"), exports);
24
25
  __exportStar(require("./ScanPackageCallContext"), exports);
25
26
  __exportStar(require("./DependencyType"), exports);
26
27
  __exportStar(require("./ParentPackageInfo"), exports);
27
28
  __exportStar(require("./LicenseAnalysisResult"), exports);
28
29
  __exportStar(require("./LicenseStats"), exports);
30
+ __exportStar(require("./LicenseExpression"), exports);
31
+ __exportStar(require("./LicenseFile"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@callstack/licenses",
3
- "version": "0.3.2",
3
+ "version": "1.0.0",
4
4
  "description": "API package that exposes functionalities for programmatic scanning of licenses in Node.js projects.",
5
5
  "browser": "./build/index.js",
6
6
  "main": "./build/node/index.js",
@@ -64,14 +64,17 @@
64
64
  "registry": "https://registry.npmjs.org/"
65
65
  },
66
66
  "dependencies": {
67
- "glob": "^7.1.3"
67
+ "glob": "^13.0.0",
68
+ "spdx-exceptions": "^2.1.0",
69
+ "spdx-expression-parse": "^5.0.0",
70
+ "spdx-license-ids": "^3.0.24"
68
71
  },
69
72
  "devDependencies": {
70
- "@babel/core": "7.27.4",
71
- "@babel/preset-env": "7.27.2",
72
- "@babel/preset-typescript": "7.27.1",
73
- "@types/glob": "^8.1.0",
73
+ "@babel/core": "^7.29.7",
74
+ "@babel/preset-env": "^7.29.7",
75
+ "@babel/preset-typescript": "^7.29.7",
74
76
  "@types/jest": "^29.5.5",
77
+ "@types/spdx-expression-parse": "^4.0.0",
75
78
  "babel-jest": "^29.7.0",
76
79
  "jest": "^29.7.0",
77
80
  "rimraf": "^6.0.1",
@@ -1,20 +1,27 @@
1
1
  /**
2
2
  * https://spdx.org/licenses
3
+ *
4
+ * Canonical License Identifiers, as produced by parsing a License Expression: deprecated identifiers are upgraded
5
+ * (`GPL-2.0` becomes `GPL-2.0-only`) and `+` is absorbed (`GPL-2.0+` becomes `GPL-2.0-or-later`), so neither form is listed.
6
+ * The exception are deprecated identifiers with a built-in License Exception, which have no `-only` form.
3
7
  */
4
8
 
5
9
  export const STRONG_COPYLEFT_LICENSES = new Set([
6
- 'GPL',
7
- 'GPL-1.0',
8
- 'GPL-1.0+',
9
- 'GPL-2.0',
10
- 'GPL-2.0+',
10
+ 'GPL-1.0-only',
11
+ 'GPL-1.0-or-later',
11
12
  'GPL-2.0-only',
12
13
  'GPL-2.0-or-later',
13
- 'GPL-3.0',
14
- 'GPL-3.0+',
14
+ 'GPL-2.0-with-autoconf-exception',
15
+ 'GPL-2.0-with-bison-exception',
16
+ 'GPL-2.0-with-classpath-exception',
17
+ 'GPL-2.0-with-font-exception',
18
+ 'GPL-2.0-with-GCC-exception',
19
+ 'GPL-3.0-with-autoconf-exception',
20
+ 'GPL-3.0-with-GCC-exception',
15
21
  'GPL-3.0-only',
16
22
  'GPL-3.0-or-later',
17
- 'AGPL-3.0',
23
+ 'AGPL-1.0-only',
24
+ 'AGPL-1.0-or-later',
18
25
  'AGPL-3.0-only',
19
26
  'AGPL-3.0-or-later',
20
27
  'EUPL-1.0',
@@ -32,17 +39,10 @@ export const WEAK_COPYLEFT_LICENSES = new Set([
32
39
  'CDDL-1.1',
33
40
  'EPL-1.0',
34
41
  'EPL-2.0',
35
- 'LGPL',
36
- 'LGPL-2.0',
37
- 'LGPL-2.0+',
38
42
  'LGPL-2.0-only',
39
43
  'LGPL-2.0-or-later',
40
- 'LGPL-2.1',
41
- 'LGPL-2.1+',
42
44
  'LGPL-2.1-only',
43
45
  'LGPL-2.1-or-later',
44
- 'LGPL-3.0',
45
- 'LGPL-3.0+',
46
46
  'LGPL-3.0-only',
47
47
  'LGPL-3.0-or-later',
48
48
  'MPL-1.1',
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Enumerates all valid OR Policy values
3
+ *
4
+ * @see {@link OrPolicy}
5
+ */
6
+ export const validOrPolicies = ['most-restrictive', 'least-restrictive'] as const;
7
+
8
+ /**
9
+ * Rule for which operand of a Dual License (`A OR B`) determines its License Category:
10
+ * - 'most-restrictive' (the most restrictive operand)
11
+ * - 'least-restrictive' (the least restrictive operand; matches SPDX's "choice" meaning of OR)
12
+ *
13
+ * Never applies to AND, which always takes the most restrictive operand.
14
+ */
15
+ export type OrPolicy = (typeof validOrPolicies)[number];
16
+
17
+ /**
18
+ * OR Policy used when none is given: a Dual License takes its most restrictive operand,
19
+ * as a conservative default for compliance checks.
20
+ *
21
+ * @see {@link OrPolicy}
22
+ */
23
+ export const DEFAULT_OR_POLICY: OrPolicy = 'most-restrictive';
@@ -1,3 +1,6 @@
1
1
  export * from './LicenseCategory';
2
2
  export * from './descriptions';
3
3
  export * from './licenseAnalysis';
4
+ export * from './OrPolicy';
5
+ export * from './licenseExpression';
6
+ export * from './licenseClassification';
@@ -1,40 +1,27 @@
1
- import { STRONG_COPYLEFT_LICENSES_LOWERCASE, WEAK_COPYLEFT_LICENSES_LOWERCASE } from '../constants/licenses';
2
1
  import type { AggregatedLicensesMapping } from '../types';
3
2
  import type { LicenseAnalysisResult } from '../types/LicenseAnalysisResult';
4
3
 
5
4
  import { LicenseCategory } from './LicenseCategory';
5
+ import type { OrPolicy } from './OrPolicy';
6
+ import { DEFAULT_OR_POLICY } from './OrPolicy';
6
7
  import { getGraphStateInfo } from './descriptions';
7
-
8
- /**
9
- * Categorizes a license based on its copyleft characteristics.
10
- * @param licenseType the license type
11
- * @returns the license category
12
- */
13
- export function categorizeLicense(licenseType?: string): LicenseCategory {
14
- if (!licenseType || licenseType === 'unknown') {
15
- return LicenseCategory.UNKNOWN;
16
- }
17
-
18
- // check for strong copyleft licenses
19
- if (STRONG_COPYLEFT_LICENSES_LOWERCASE.has(licenseType.toLowerCase())) {
20
- return LicenseCategory.STRONG_COPYLEFT;
21
- }
22
-
23
- // check for weak copyleft licenses
24
- if (WEAK_COPYLEFT_LICENSES_LOWERCASE.has(licenseType.toLowerCase())) {
25
- return LicenseCategory.WEAK_COPYLEFT;
26
- }
27
-
28
- // everything else is considered permissive
29
- return LicenseCategory.PERMISSIVE;
30
- }
8
+ import { classifyLicenseExpression } from './licenseClassification';
9
+ import { renderLicenseExpression } from './licenseExpression';
31
10
 
32
11
  /**
33
12
  * Analyzes license data and returns comprehensive statistics.
13
+ *
14
+ * Each package is classified by its License Expression and grouped by the rendered expression,
15
+ * so a Dual License such as `MIT OR Apache-2.0` is counted under one key.
16
+ *
34
17
  * @param report the licenses report data
18
+ * @param orPolicy the OR Policy used to classify Dual Licenses; defaults to {@link DEFAULT_OR_POLICY}
35
19
  * @returns the license analysis result
36
20
  */
37
- export function analyzeLicenses(report: AggregatedLicensesMapping): LicenseAnalysisResult {
21
+ export function analyzeLicenses(
22
+ report: AggregatedLicensesMapping,
23
+ orPolicy: OrPolicy = DEFAULT_OR_POLICY,
24
+ ): LicenseAnalysisResult {
38
25
  const byCategory: Record<LicenseCategory, number> = {
39
26
  [LicenseCategory.STRONG_COPYLEFT]: 0,
40
27
  [LicenseCategory.WEAK_COPYLEFT]: 0,
@@ -43,21 +30,19 @@ export function analyzeLicenses(report: AggregatedLicensesMapping): LicenseAnaly
43
30
  };
44
31
 
45
32
  const byLicense: Record<string, number> = {};
33
+ const categoryByLicense: Record<string, LicenseCategory> = {};
46
34
  const categorizedLicenses: Record<string, LicenseCategory> = {};
47
35
 
48
36
  Object.entries(report).forEach(([packageKey, license]) => {
49
- const licenseType = license.type;
50
- const category = categorizeLicense(licenseType);
37
+ const category = classifyLicenseExpression(license.license, orPolicy);
38
+ const renderedLicense = renderLicenseExpression(license.license);
51
39
 
52
40
  // stats by category
53
41
  byCategory[category]++;
54
42
 
55
43
  // stats by specific license
56
- {
57
- const key = licenseType ?? 'unknown';
58
-
59
- byLicense[key] = (byLicense[key] ?? 0) + 1;
60
- }
44
+ byLicense[renderedLicense] = (byLicense[renderedLicense] ?? 0) + 1;
45
+ categoryByLicense[renderedLicense] = category;
61
46
 
62
47
  // memoization for lookup
63
48
  categorizedLicenses[packageKey] = category;
@@ -71,6 +56,7 @@ export function analyzeLicenses(report: AggregatedLicensesMapping): LicenseAnaly
71
56
  total,
72
57
  byCategory,
73
58
  byLicense,
59
+ categoryByLicense,
74
60
  description,
75
61
  categoriesPresence,
76
62
  categorizedLicenses,
@@ -0,0 +1,173 @@
1
+ import { STRONG_COPYLEFT_LICENSES_LOWERCASE, WEAK_COPYLEFT_LICENSES_LOWERCASE } from '../constants';
2
+ import type { LicenseExpression } from '../types';
3
+
4
+ import { LicenseCategory } from './LicenseCategory';
5
+ import type { OrPolicy } from './OrPolicy';
6
+ import { DEFAULT_OR_POLICY } from './OrPolicy';
7
+
8
+ const CATEGORY_RANK: Record<LicenseCategory, number> = {
9
+ [LicenseCategory.PERMISSIVE]: 0,
10
+ [LicenseCategory.WEAK_COPYLEFT]: 1,
11
+ [LicenseCategory.STRONG_COPYLEFT]: 2,
12
+ [LicenseCategory.UNKNOWN]: 3,
13
+ };
14
+
15
+ const OR_COMPARISON_STRATEGY: Record<OrPolicy, (a: LicenseCategory, b: LicenseCategory) => LicenseCategory> = {
16
+ 'most-restrictive': takeMoreRestrictive,
17
+ 'least-restrictive': takeLessRestrictive,
18
+ };
19
+
20
+ /**
21
+ * Categorizes a canonical License Identifier from a parsed License Expression.
22
+ * Not for raw strings: an identifier outside the copyleft tables is assumed permissive,
23
+ * which is only safe because the parser rejects unrecognised identifiers.
24
+ */
25
+ function categorizeLicenseId(licenseId: string): LicenseCategory {
26
+ if (STRONG_COPYLEFT_LICENSES_LOWERCASE.has(licenseId.toLowerCase())) {
27
+ return LicenseCategory.STRONG_COPYLEFT;
28
+ }
29
+
30
+ if (WEAK_COPYLEFT_LICENSES_LOWERCASE.has(licenseId.toLowerCase())) {
31
+ return LicenseCategory.WEAK_COPYLEFT;
32
+ }
33
+
34
+ return LicenseCategory.PERMISSIVE;
35
+ }
36
+
37
+ /**
38
+ * Classifies a License Expression into a License Category.
39
+ *
40
+ * Categories are ordered permissive < weak copyleft < strong copyleft < unknown:
41
+ * - a license takes the category of its License Identifier;
42
+ * - an Unknown License is unknown;
43
+ * - AND always takes its most restrictive operand;
44
+ * - OR takes the operand chosen by the OR Policy.
45
+ *
46
+ * @param expression the License Expression to classify
47
+ * @param orPolicy the OR Policy applied to every OR in the expression; defaults to {@link DEFAULT_OR_POLICY}
48
+ * @returns the License Category of the whole expression
49
+ * @example
50
+ * classifyLicenseExpression(parseLicenseExpression('MIT OR GPL-3.0-only'), 'least-restrictive');
51
+ * // LicenseCategory.PERMISSIVE
52
+ */
53
+ export function classifyLicenseExpression(
54
+ expression: LicenseExpression,
55
+ orPolicy: OrPolicy = DEFAULT_OR_POLICY,
56
+ ): LicenseCategory {
57
+ switch (expression.kind) {
58
+ case 'license':
59
+ return isLicenseRef(expression.id) ? LicenseCategory.UNKNOWN : categorizeLicenseId(expression.id);
60
+ case 'unknown':
61
+ return LicenseCategory.UNKNOWN;
62
+ case 'and':
63
+ return takeMoreRestrictive(
64
+ classifyLicenseExpression(expression.left, orPolicy),
65
+ classifyLicenseExpression(expression.right, orPolicy),
66
+ );
67
+ case 'or':
68
+ return OR_COMPARISON_STRATEGY[orPolicy](
69
+ classifyLicenseExpression(expression.left, orPolicy),
70
+ classifyLicenseExpression(expression.right, orPolicy),
71
+ );
72
+ }
73
+ }
74
+
75
+ /** The copyleft that cannot be avoided for a License Expression, see {@link classifyUnavoidableCopyleft} */
76
+ export interface UnavoidableCopyleft {
77
+ /** The known copyleft that must be complied with, never unknown; permissive when no known operand requires copyleft */
78
+ category: LicenseCategory;
79
+ /** `true` when an Unknown operand could require more than `category`, so `category` is only a lower bound */
80
+ unidentified: boolean;
81
+ }
82
+
83
+ /**
84
+ * Decides which copyleft cannot be avoided for a License Expression under an OR Policy.
85
+ *
86
+ * Unknown operands are ignored, so a custom license next to a copyleft one cannot hide the copyleft:
87
+ * - AND requires the most restrictive known operand;
88
+ * - OR with an Unknown operand requires what the other operand requires;
89
+ * - OR with two known operands follows the OR Policy;
90
+ * - an expression with no known operand requires no copyleft.
91
+ *
92
+ * Ignoring an Unknown operand can also understate the result (`MIT AND LicenseRef-Custom`, or an expression
93
+ * that is wholly Unknown), so the result says when that happened: `unidentified` is `true` when an Unknown operand
94
+ * could require more than the category under the OR Policy and the category is not already strong copyleft,
95
+ * the most restrictive known category. `MIT OR LicenseRef-Custom` is only unidentified under the most restrictive OR Policy.
96
+ *
97
+ * @param expression the License Expression to check
98
+ * @param orPolicy the OR Policy applied to every OR in the expression; defaults to {@link DEFAULT_OR_POLICY}
99
+ * @returns the known copyleft category, and whether an Unknown operand leaves it unidentified
100
+ * @example
101
+ * classifyUnavoidableCopyleft(parseLicenseExpression('GPL-3.0-only AND LicenseRef-Custom'));
102
+ * // { category: LicenseCategory.STRONG_COPYLEFT, unidentified: false }
103
+ * @example
104
+ * classifyUnavoidableCopyleft(parseLicenseExpression('MIT AND LicenseRef-Custom'));
105
+ * // { category: LicenseCategory.PERMISSIVE, unidentified: true }
106
+ */
107
+ export function classifyUnavoidableCopyleft(
108
+ expression: LicenseExpression,
109
+ orPolicy: OrPolicy = DEFAULT_OR_POLICY,
110
+ ): UnavoidableCopyleft {
111
+ const category = findKnownCopyleft(expression, orPolicy) ?? LicenseCategory.PERMISSIVE;
112
+
113
+ return {
114
+ category,
115
+ unidentified: category !== LicenseCategory.STRONG_COPYLEFT && hasUnknownOperand(expression, orPolicy),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Whether an Unknown operand could require more than {@link findKnownCopyleft} found under the OR Policy.
121
+ *
122
+ * An OR with a known operand only has this risk under the most restrictive OR Policy: under the least restrictive
123
+ * one the known operand can always be chosen instead of the Unknown one.
124
+ */
125
+ function hasUnknownOperand(expression: LicenseExpression, orPolicy: OrPolicy): boolean {
126
+ switch (expression.kind) {
127
+ case 'unknown':
128
+ return true;
129
+ case 'license':
130
+ return classifyLicenseExpression(expression, orPolicy) === LicenseCategory.UNKNOWN;
131
+ case 'and':
132
+ return hasUnknownOperand(expression.left, orPolicy) || hasUnknownOperand(expression.right, orPolicy);
133
+ case 'or':
134
+ return orPolicy === 'least-restrictive'
135
+ ? hasUnknownOperand(expression.left, orPolicy) && hasUnknownOperand(expression.right, orPolicy)
136
+ : hasUnknownOperand(expression.left, orPolicy) || hasUnknownOperand(expression.right, orPolicy);
137
+ }
138
+ }
139
+
140
+ /** Like {@link classifyUnavoidableCopyleft}, but `null` when no operand has a known category */
141
+ function findKnownCopyleft(expression: LicenseExpression, orPolicy: OrPolicy): LicenseCategory | null {
142
+ if (expression.kind === 'unknown') {
143
+ return null;
144
+ }
145
+
146
+ if (expression.kind === 'license') {
147
+ const category = classifyLicenseExpression(expression, orPolicy);
148
+
149
+ return category === LicenseCategory.UNKNOWN ? null : category;
150
+ }
151
+
152
+ const left = findKnownCopyleft(expression.left, orPolicy);
153
+ const right = findKnownCopyleft(expression.right, orPolicy);
154
+
155
+ if (left === null || right === null) {
156
+ return left ?? right;
157
+ }
158
+
159
+ return expression.kind === 'and' ? takeMoreRestrictive(left, right) : OR_COMPARISON_STRATEGY[orPolicy](left, right);
160
+ }
161
+
162
+ /** A custom license reference, optionally qualified by a document: `LicenseRef-X` or `DocumentRef-Y:LicenseRef-X` */
163
+ function isLicenseRef(id: string): boolean {
164
+ return /^(DocumentRef-[^:]+:)?LicenseRef-/.test(id);
165
+ }
166
+
167
+ function takeMoreRestrictive(a: LicenseCategory, b: LicenseCategory): LicenseCategory {
168
+ return CATEGORY_RANK[a] >= CATEGORY_RANK[b] ? a : b;
169
+ }
170
+
171
+ function takeLessRestrictive(a: LicenseCategory, b: LicenseCategory): LicenseCategory {
172
+ return CATEGORY_RANK[a] <= CATEGORY_RANK[b] ? a : b;
173
+ }