diff --git a/packages/angular/build/src/builders/library/options.ts b/packages/angular/build/src/builders/library/options.ts index 7f74954de60b..2c94b80f9568 100644 --- a/packages/angular/build/src/builders/library/options.ts +++ b/packages/angular/build/src/builders/library/options.ts @@ -295,6 +295,10 @@ function normalizeEntryPoints( const exportsRecord = typeof rawExports === 'string' ? { '.': rawExports } : rawExports; const entryPoints = new Map(); + // Bundle names flatten '/' to '-', so two distinct subpaths can produce one. + // Keyed case-insensitively: names differing only in case are distinct keys in + // the bundler input map but one file on a case-insensitive filesystem. + const bundleNameOrigins = new Map(); let hasPrimary = false; for (const [key, value] of Object.entries(exportsRecord)) { @@ -346,6 +350,17 @@ function normalizeEntryPoints( ); } + const bundleNameKey = entryPoint.bundleName.toLowerCase(); + const collidingKey = bundleNameOrigins.get(bundleNameKey); + if (collidingKey !== undefined) { + throw new Error( + `Entry points '${collidingKey}' and '${key}' both produce the bundle name ` + + `'${entryPoint.bundleName}'. Entry point subpaths must not differ only by ` + + `'/' versus '-', or by case.`, + ); + } + + bundleNameOrigins.set(bundleNameKey, key); entryPoints.set(entryPoint.name, entryPoint); if (entryPoint.isPrimary) { diff --git a/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts b/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts index cf1bb1b6e7b7..05ddeb76fca5 100644 --- a/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts +++ b/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts @@ -45,6 +45,86 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => expect(result?.success).toBeTrue(); }); + it('should succeed with a nested entry point beside a similarly named flat one', async () => { + await harness.writeFiles({ + 'projects/lib/zz/child/public-api.ts': 'export const NESTED = 42;\n', + 'projects/lib/zz-sibling/public-api.ts': 'export const FLAT = 7;\n', + }); + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './zz/child': './zz/child/public-api.ts', + './zz-sibling': './zz-sibling/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/lib/fesm2022/lib-zz-child.mjs').toExist(); + harness.expectFile('dist/lib/fesm2022/lib-zz-sibling.mjs').toExist(); + }); + + it('should fail when two entry points produce the same bundle name', async () => { + // A bundle name flattens '/' to '-', so './zz/child' and './zz-child' both + // become 'lib-zz-child'. Without a guard the second silently overwrites the + // first in the bundler input map: the build exits 0, one entry point's code + // is gone, and both 'exports' keys resolve to the surviving file. + await harness.writeFiles({ + 'projects/lib/zz/child/public-api.ts': 'export const NESTED = 42;\n', + 'projects/lib/zz-child/public-api.ts': 'export const FLAT = 7;\n', + }); + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './zz/child': './zz/child/public-api.ts', + './zz-child': './zz-child/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result, error } = await harness.executeOnce({ + outputLogsOnException: false, + outputLogsOnFailure: false, + }); + expect(result).toBeUndefined(); + expect(error).toBeDefined(); + expect((error as Error).message).toMatch( + /both produce the bundle name 'lib-zz-child'/, + ); + }); + + it('should fail when two entry points differ only by case', async () => { + // Distinct keys in the bundler input map, but one file on a case-insensitive + // filesystem, so the emitted bundle depends on which OS ran the build. + await harness.writeFiles({ + 'projects/lib/Zz/public-api.ts': 'export const UPPER = 42;\n', + 'projects/lib/zz/public-api.ts': 'export const LOWER = 7;\n', + }); + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './Zz': './Zz/public-api.ts', + './zz': './zz/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result, error } = await harness.executeOnce({ + outputLogsOnException: false, + outputLogsOnFailure: false, + }); + expect(result).toBeUndefined(); + expect(error).toBeDefined(); + expect((error as Error).message).toMatch(/both produce the bundle name/); + }); + it('should fail when entry point is not a .ts or .mts file', async () => { await harness.modifyFile('projects/lib/package.json', (content) => { const pkg = JSON.parse(content);