From 000ec679dd53cdf10b12facb42efcfd8d5f37abd Mon Sep 17 00:00:00 2001 From: rk Date: Wed, 30 Sep 2026 23:34:18 +0500 Subject: [PATCH 1/2] fix(@angular/build): reject entry points that collide on bundle name A bundle name flattens '/' to '-', so './zz/child' and './zz-child' both become 'lib-zz-child'. The bundler input map is keyed by that name, so the second entry point silently overwrote the first: the build exited 0, one entry point's code was missing, and both 'exports' keys resolved to the surviving file. Fixes #34128 --- .../build/src/builders/library/options.ts | 11 ++++ .../tests/options/entry-points_spec.ts | 53 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/angular/build/src/builders/library/options.ts b/packages/angular/build/src/builders/library/options.ts index 7f74954de60b..f14ed57c81be 100644 --- a/packages/angular/build/src/builders/library/options.ts +++ b/packages/angular/build/src/builders/library/options.ts @@ -295,6 +295,8 @@ function normalizeEntryPoints( const exportsRecord = typeof rawExports === 'string' ? { '.': rawExports } : rawExports; const entryPoints = new Map(); + // Bundle names flatten '/' to '-', so two distinct subpaths can produce one. + const bundleNameOrigins = new Map(); let hasPrimary = false; for (const [key, value] of Object.entries(exportsRecord)) { @@ -346,6 +348,15 @@ function normalizeEntryPoints( ); } + const collidingKey = bundleNameOrigins.get(entryPoint.bundleName); + 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 '-'.`, + ); + } + + bundleNameOrigins.set(entryPoint.bundleName, 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..f0b076c21bbd 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,59 @@ 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 entry point is not a .ts or .mts file', async () => { await harness.modifyFile('projects/lib/package.json', (content) => { const pkg = JSON.parse(content); From 7362e85dfc9609daa70a6908bd761d374caa62b1 Mon Sep 17 00:00:00 2001 From: rk Date: Wed, 30 Sep 2026 23:44:47 +0500 Subject: [PATCH 2/2] fixup: also reject names that collide only by case --- .../build/src/builders/library/options.ts | 10 ++++--- .../tests/options/entry-points_spec.ts | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/angular/build/src/builders/library/options.ts b/packages/angular/build/src/builders/library/options.ts index f14ed57c81be..2c94b80f9568 100644 --- a/packages/angular/build/src/builders/library/options.ts +++ b/packages/angular/build/src/builders/library/options.ts @@ -296,6 +296,8 @@ function normalizeEntryPoints( 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; @@ -348,15 +350,17 @@ function normalizeEntryPoints( ); } - const collidingKey = bundleNameOrigins.get(entryPoint.bundleName); + 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 '-'.`, + `'${entryPoint.bundleName}'. Entry point subpaths must not differ only by ` + + `'/' versus '-', or by case.`, ); } - bundleNameOrigins.set(entryPoint.bundleName, key); + 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 f0b076c21bbd..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 @@ -98,6 +98,33 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => ); }); + 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);