Skip to content

Commit 25b7cf7

Browse files
committed
fix(@angular/build): support case-insensitive and alternative license file names
When extracting third-party license files in `license-extractor`, replace the hardcoded list of uppercase filenames with a directory scan using `readdir(packageDirectory, { withFileTypes: true })` and matching against `/^(?:mit-)?licen[cs]e(?:$|[-._])/i`. This enables support for lowercase license files (`license`) on case-sensitive file systems, British English spelling (`LICENCE`), and prefixed/suffixed variations (`MIT-LICENCE.txt`, `LICENSE-MIT`, `LICENSE.BSD`). In addition, deduplicate package directory paths before reading and parsing `package.json` to reduce redundant disk I/O, and fix an off-by-one truncation bug when handling custom "SEE LICENSE IN <filename>" packages. Closes #33741 (cherry picked from commit e1c7193)
1 parent 212373a commit 25b7cf7

2 files changed

Lines changed: 112 additions & 12 deletions

File tree

packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,96 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
5555
harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('MIT');
5656
harness.expectFile('dist/browser/en-US/main.js').toExist();
5757
});
58+
59+
it(`should extract license from a package with a lowercase 'license' file`, async () => {
60+
await harness.writeFile(
61+
'node_modules/test-package-a/package.json',
62+
JSON.stringify({
63+
name: 'test-package-a',
64+
version: '1.0.0',
65+
main: 'index.js',
66+
license: 'MIT',
67+
}),
68+
);
69+
await harness.writeFile(
70+
'node_modules/test-package-a/index.js',
71+
'console.log("test-package-a");',
72+
);
73+
await harness.writeFile('node_modules/test-package-a/license', 'TEST_LOWERCASE_LICENSE_TEXT');
74+
await harness.appendToFile('src/main.ts', "\nimport 'test-package-a';\n");
75+
76+
harness.useTarget('build', {
77+
...BASE_OPTIONS,
78+
extractLicenses: true,
79+
});
80+
81+
const { result } = await harness.executeOnce();
82+
expect(result?.success).toBeTrue();
83+
harness
84+
.expectFile('dist/3rdpartylicenses.txt')
85+
.content.toContain('TEST_LOWERCASE_LICENSE_TEXT');
86+
});
87+
88+
it(`should extract license from a package with an alternative license file name (e.g., 'MIT-LICENCE.txt')`, async () => {
89+
await harness.writeFile(
90+
'node_modules/test-package-b/package.json',
91+
JSON.stringify({
92+
name: 'test-package-b',
93+
version: '1.0.0',
94+
main: 'index.js',
95+
license: 'MIT',
96+
}),
97+
);
98+
await harness.writeFile(
99+
'node_modules/test-package-b/index.js',
100+
'console.log("test-package-b");',
101+
);
102+
await harness.writeFile(
103+
'node_modules/test-package-b/MIT-LICENCE.txt',
104+
'TEST_ALTERNATIVE_LICENSE_TEXT',
105+
);
106+
await harness.appendToFile('src/main.ts', "\nimport 'test-package-b';\n");
107+
108+
harness.useTarget('build', {
109+
...BASE_OPTIONS,
110+
extractLicenses: true,
111+
});
112+
113+
const { result } = await harness.executeOnce();
114+
expect(result?.success).toBeTrue();
115+
harness
116+
.expectFile('dist/3rdpartylicenses.txt')
117+
.content.toContain('TEST_ALTERNATIVE_LICENSE_TEXT');
118+
});
119+
120+
it(`should extract license from a package with a custom license file specified in package.json`, async () => {
121+
await harness.writeFile(
122+
'node_modules/test-package-c/package.json',
123+
JSON.stringify({
124+
name: 'test-package-c',
125+
version: '1.0.0',
126+
main: 'index.js',
127+
license: 'SEE LICENSE IN custom-license.md',
128+
}),
129+
);
130+
await harness.writeFile(
131+
'node_modules/test-package-c/index.js',
132+
'console.log("test-package-c");',
133+
);
134+
await harness.writeFile(
135+
'node_modules/test-package-c/custom-license.md',
136+
'TEST_CUSTOM_LICENSE_TEXT',
137+
);
138+
await harness.appendToFile('src/main.ts', "\nimport 'test-package-c';\n");
139+
140+
harness.useTarget('build', {
141+
...BASE_OPTIONS,
142+
extractLicenses: true,
143+
});
144+
145+
const { result } = await harness.executeOnce();
146+
expect(result?.success).toBeTrue();
147+
harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('TEST_CUSTOM_LICENSE_TEXT');
148+
});
58149
});
59150
});

packages/angular/build/src/tools/esbuild/license-extractor.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import type { Metafile } from 'esbuild';
10-
import { readFile } from 'node:fs/promises';
10+
import { readFile, readdir } from 'node:fs/promises';
1111
import path from 'node:path';
1212

1313
/**
@@ -30,9 +30,9 @@ const NODE_MODULE_SEGMENT = 'node_modules';
3030
const CUSTOM_LICENSE_TEXT = 'SEE LICENSE IN ';
3131

3232
/**
33-
* A list of commonly named license files found within packages.
33+
* A regular expression for commonly named license files found within packages.
3434
*/
35-
const LICENSE_FILES = ['LICENSE', 'LICENSE.txt', 'LICENSE.md'];
35+
const LICENSE_FILE_REGEXP = /^(?:mit-)?licen[cs]e(?:$|[-._])/i;
3636

3737
/**
3838
* Header text that will be added to the top of the output license extraction file.
@@ -64,6 +64,7 @@ export async function extractLicenses(metafile: Metafile, rootDirectory: string)
6464
let extractedLicenseContent = `${EXTRACTION_FILE_HEADER}\n${EXTRACTION_FILE_SEPARATOR}`;
6565

6666
const seenPaths = new Set<string>();
67+
const seenPackageDirectories = new Set<string>();
6768
const seenPackages = new Set<string>();
6869

6970
for (const entry of Object.values(metafile.outputs)) {
@@ -110,6 +111,11 @@ export async function extractLicenses(metafile: Metafile, rootDirectory: string)
110111
: nameOrScope;
111112
const packageDirectory = path.join(baseDirectory, packageName);
112113

114+
if (seenPackageDirectories.has(packageDirectory)) {
115+
continue;
116+
}
117+
seenPackageDirectories.add(packageDirectory);
118+
113119
// Load the package's metadata to find the package's name, version, and license type
114120
const packageJsonPath = path.join(packageDirectory, 'package.json');
115121
let packageJson;
@@ -136,12 +142,12 @@ export async function extractLicenses(metafile: Metafile, rootDirectory: string)
136142
let licenseText = '';
137143
if (
138144
typeof packageJson.license === 'string' &&
139-
packageJson.license.toLowerCase().startsWith(CUSTOM_LICENSE_TEXT)
145+
packageJson.license.toUpperCase().startsWith(CUSTOM_LICENSE_TEXT)
140146
) {
141147
// Attempt to load the package's custom license
142148
let customLicensePath;
143149
const customLicenseFile = path.normalize(
144-
packageJson.license.slice(CUSTOM_LICENSE_TEXT.length + 1).trim(),
150+
packageJson.license.slice(CUSTOM_LICENSE_TEXT.length).trim(),
145151
);
146152
if (customLicenseFile.startsWith('..') || path.isAbsolute(customLicenseFile)) {
147153
// Path is attempting to access files outside of the package
@@ -150,17 +156,20 @@ export async function extractLicenses(metafile: Metafile, rootDirectory: string)
150156
customLicensePath = path.join(packageDirectory, customLicenseFile);
151157
try {
152158
licenseText = await readFile(customLicensePath, 'utf-8');
153-
break;
154159
} catch {}
155160
}
156161
} else {
157162
// Search for a license file within the root of the package
158-
for (const potentialLicense of LICENSE_FILES) {
159-
const packageLicensePath = path.join(packageDirectory, potentialLicense);
160-
try {
161-
licenseText = await readFile(packageLicensePath, 'utf-8');
162-
break;
163-
} catch {}
163+
const entries = await readdir(packageDirectory, { withFileTypes: true }).catch(() => []);
164+
165+
for (const entry of entries) {
166+
if ((entry.isFile() || entry.isSymbolicLink()) && LICENSE_FILE_REGEXP.test(entry.name)) {
167+
const packageLicensePath = path.join(packageDirectory, entry.name);
168+
try {
169+
licenseText = await readFile(packageLicensePath, 'utf-8');
170+
break;
171+
} catch {}
172+
}
164173
}
165174
}
166175

0 commit comments

Comments
 (0)