From 0a82a2712922be2349e88bbdf3b6178f51848369 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:49:34 +0000 Subject: [PATCH 1/2] fix(@angular/build): avoid expanding directory patterns in asset globs Previously, when an asset entry specified a directory pattern in `glob` (such as `glob: 'sub/'` or `glob: 'sub'`), its behavior differed depending on whether `ignore` was present: without `ignore`, the static single-file fast-path returned no files, whereas with `ignore`, `tinyglobby` ran with its default `expandDirectories: true` option and recursively matched all files in the directory. This change disables `expandDirectories` for `glob` in `resolveAssets` while preserving directory expansion for `ignore` patterns via a shared `normalizeAssetIgnorePatterns` helper. --- .../src/builders/library/pipeline/assets.ts | 42 +++-------- .../builders/library/pipeline/assets_spec.ts | 15 ++++ .../utils/normalize-asset-patterns_spec.ts | 59 +++++++++++++++ .../angular/build/src/utils/resolve-assets.ts | 75 +++++++++++++++++-- 4 files changed, 151 insertions(+), 40 deletions(-) diff --git a/packages/angular/build/src/builders/library/pipeline/assets.ts b/packages/angular/build/src/builders/library/pipeline/assets.ts index 81ffb98da8cd..83969c04e885 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -10,7 +10,7 @@ import { lstatSync, statSync } from 'node:fs'; import path from 'node:path'; import picomatch from 'picomatch'; import type { NormalizedAssetsPattern } from '../../../utils'; -import { DEFAULT_ASSET_IGNORE, resolveAssets } from '../../../utils/resolve-assets'; +import { normalizeAssetIgnorePatterns, resolveAssets } from '../../../utils/resolve-assets'; import { type DiskOutputFile, createDiskOutputFile } from './utils'; /** @@ -38,30 +38,6 @@ export interface AssetMatcher { isMatch: picomatch.Matcher; } -/** - * Normalizes an ignore glob pattern for `picomatch` to match `tinyglobby` directory-exclusion - * semantics by stripping trailing slashes, normalizing POSIX segments, and expanding patterns - * with `/**` (returning both `normalized` and `${normalized}/**` for single-wildcard suffixes - * because `picomatch` does not make trailing `/**` optional after `*`). - * - * @param pattern The raw ignore glob pattern string. - * @returns The normalized POSIX glob patterns. - */ -function normalizeGlobPattern(pattern: string): string[] { - const trimmed = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern; - const normalized = path.posix.normalize(trimmed); - - if (!normalized.endsWith('*')) { - return [`${normalized}/**`]; - } - - if (normalized.endsWith('/**')) { - return [normalized]; - } - - return [normalized, `${normalized}/**`]; -} - /** * Resolves the candidate POSIX source file path within `matcher` that would map to `outputPath`, * or `undefined` if `outputPath` is outside `matcher.asset.output` or does not match `matcher`. @@ -96,14 +72,14 @@ function isMatchingAssetFile(file: string, followSymlinks: boolean | undefined): export function createAssetMatchers(assets: NormalizedAssetsPattern[]): readonly AssetMatcher[] { return assets.map((asset) => { const { glob: rawGlob, ignore: rawIgnore, input } = asset; - const ignore = [...DEFAULT_ASSET_IGNORE, ...(rawIgnore ?? [])] - .filter((pattern) => pattern.length > 0) - .flatMap(normalizeGlobPattern); - const isMatch = picomatch(rawGlob ? path.posix.normalize(rawGlob) : [], { - dot: true, - windows: false, - ignore, - }); + const isMatch = picomatch( + rawGlob && !rawGlob.endsWith('/') ? path.posix.normalize(rawGlob) : [], + { + dot: true, + windows: false, + ignore: normalizeAssetIgnorePatterns(rawIgnore), + }, + ); const prefix = input.endsWith('/') ? input : `${input}/`; return { asset, prefix, isMatch }; diff --git a/packages/angular/build/src/builders/library/pipeline/assets_spec.ts b/packages/angular/build/src/builders/library/pipeline/assets_spec.ts index 19c692fc06b7..faa0c8a68785 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets_spec.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets_spec.ts @@ -84,6 +84,21 @@ describe('library pipeline assets', () => { expect(matchers).toHaveSize(1); expect(matchers[0].isMatch('any.txt')).toBeFalse(); }); + + it('should not match files when glob has a trailing slash', () => { + const matchers = createAssetMatchers([ + { + glob: 'sub/', + input: `${tempRoot}/assets`, + output: 'assets', + ignore: ['ignored.txt'], + }, + ]); + + expect(matchers).toHaveSize(1); + expect(matchers[0].isMatch('sub')).toBeFalse(); + expect(matchers[0].isMatch('sub/file.txt')).toBeFalse(); + }); }); describe('collectAssetsToEmit', () => { diff --git a/packages/angular/build/src/utils/normalize-asset-patterns_spec.ts b/packages/angular/build/src/utils/normalize-asset-patterns_spec.ts index 711a6716afb1..d83a74fe0ee8 100644 --- a/packages/angular/build/src/utils/normalize-asset-patterns_spec.ts +++ b/packages/angular/build/src/utils/normalize-asset-patterns_spec.ts @@ -166,4 +166,63 @@ describe('resolveAssets', () => { resolveAssets([{ glob: '../*.txt', input: 'src', output: '.' }], tempRoot), ).toBeRejectedWithError('An asset cannot be written to a location outside of the output path.'); }); + + it('should not expand directory patterns in glob whether or not ignore is present', async () => { + await mkdir(join(tempRoot, 'src/assets/sub'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/sub/file.txt'), 'content'); + await writeFile(join(tempRoot, 'src/assets/sub/ignored.txt'), 'ignored'); + await writeFile(join(tempRoot, 'src/assets/root.txt'), 'root'); + + const resolved = await resolveAssets( + [ + { glob: 'sub/', input: 'src/assets', output: 'assets' }, + { glob: 'sub/', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] }, + { glob: 'sub', input: 'src/assets', output: 'assets' }, + { glob: 'sub', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] }, + { glob: 'root.txt/', input: 'src/assets', output: 'assets' }, + { glob: 'root.txt/', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] }, + ], + tempRoot, + ); + + expect(resolved).toHaveSize(0); + }); + + it('should expand directory patterns in ignore while keeping non-ignored files', async () => { + await mkdir(join(tempRoot, 'src/assets/sub-a/nested'), { recursive: true }); + await mkdir(join(tempRoot, 'src/assets/sub-b/nested'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/sub-a/nested/a.txt'), 'a'); + await writeFile(join(tempRoot, 'src/assets/sub-b/nested/b.txt'), 'b'); + await writeFile(join(tempRoot, 'src/assets/keep.txt'), 'keep'); + + const resolved = await resolveAssets( + [{ glob: '**/*', input: 'src/assets', output: 'assets', ignore: ['sub-a/', 'sub-b'] }], + tempRoot, + ); + + expect(resolved).toEqual([ + { + source: join(tempRoot, 'src/assets/keep.txt'), + destination: join('assets', 'keep.txt'), + }, + ]); + }); + + it('should ignore default ignored files even when specified as a static file glob without ignore', async () => { + await mkdir(join(tempRoot, 'src/assets'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/.gitkeep'), ''); + await writeFile(join(tempRoot, 'src/assets/.DS_Store'), ''); + await writeFile(join(tempRoot, 'src/assets/Thumbs.db'), ''); + + const resolved = await resolveAssets( + [ + { glob: '.gitkeep', input: 'src/assets', output: 'assets' }, + { glob: '.DS_Store', input: 'src/assets', output: 'assets' }, + { glob: 'Thumbs.db', input: 'src/assets', output: 'assets' }, + ], + tempRoot, + ); + + expect(resolved).toHaveSize(0); + }); }); diff --git a/packages/angular/build/src/utils/resolve-assets.ts b/packages/angular/build/src/utils/resolve-assets.ts index 04a25e63c9db..1e7aa83922f8 100644 --- a/packages/angular/build/src/utils/resolve-assets.ts +++ b/packages/angular/build/src/utils/resolve-assets.ts @@ -12,10 +12,64 @@ import { glob, isDynamicPattern } from 'tinyglobby'; import { MAX_CONCURRENT_READS, mapConcurrent } from './concurrency'; import { isSubDirectory } from './path'; +const DEFAULT_ASSET_IGNORE_FILES: ReadonlySet = new Set([ + '.gitkeep', + '.DS_Store', + 'Thumbs.db', +]); +const DEFAULT_ASSET_IGNORE_PATTERNS: string[] = Array.from( + DEFAULT_ASSET_IGNORE_FILES, + (file) => `**/${file}`, +); + +/** + * Normalizes an ignore glob pattern to match directory-exclusion semantics by stripping + * trailing slashes, normalizing POSIX segments, and expanding patterns with `/**` + * (returning both `normalized` and `${normalized}/**` for single-wildcard suffixes + * because `picomatch` does not make trailing `/**` optional after `*`). + * + * @param pattern The raw ignore glob pattern string. + * @returns The normalized POSIX glob patterns. + */ +function normalizeIgnorePattern(pattern: string): string[] { + if (!pattern) { + return []; + } + + const posixNormalized = path.posix.normalize(pattern); + const normalized = + posixNormalized.length > 1 && posixNormalized.endsWith('/') + ? posixNormalized.slice(0, -1) + : posixNormalized; + + if (!normalized.endsWith('*')) { + return [`${normalized}/**`]; + } + + if (normalized === '**' || normalized.endsWith('/**')) { + return [normalized]; + } + + return [normalized, `${normalized}/**`]; +} + /** - * Default glob ignore patterns for assets. + * Combines default asset ignore patterns with user-provided ignore patterns and normalizes + * them so directory patterns (such as `'sub'` or `'sub/'`) exclude all nested files. + * + * @param ignore Optional user-provided ignore glob patterns. + * @returns The normalized ignore glob patterns. */ -export const DEFAULT_ASSET_IGNORE = ['**/.gitkeep', '**/.DS_Store', '**/Thumbs.db'] as const; +export function normalizeAssetIgnorePatterns(ignore?: readonly string[]): string[] { + if (!ignore?.length) { + return DEFAULT_ASSET_IGNORE_PATTERNS; + } + + return [ + ...DEFAULT_ASSET_IGNORE_PATTERNS, + ...ignore.flatMap((pattern) => normalizeIgnorePattern(pattern)), + ]; +} export async function resolveAssets( entries: { @@ -33,22 +87,28 @@ export async function resolveAssets( throw new Error(`The ${entry.input} asset path must be within the workspace root.`); } + if (!entry.glob || entry.glob.endsWith('/')) { + return []; + } + const cwd = path.resolve(root, entry.input); if (!entry.ignore?.length && !isDynamicPattern(entry.glob)) { const src = path.join(cwd, entry.glob); if (isSubDirectory(cwd, src)) { + if (DEFAULT_ASSET_IGNORE_FILES.has(path.basename(src))) { + return []; + } + try { const stats = await (entry.followSymlinks ? stat(src) : lstat(src)); if (stats.isFile()) { - const filePath = entry.flatten ? path.basename(entry.glob) : entry.glob; + const filePath = entry.flatten ? path.basename(src) : path.relative(cwd, src); return [{ source: src, destination: path.join(entry.output, filePath) }]; } - if (!stats.isSymbolicLink()) { - return []; - } + return []; } catch { // File does not exist or cannot be accessed. return []; @@ -59,7 +119,8 @@ export async function resolveAssets( const files = await glob(entry.glob, { cwd, dot: true, - ignore: entry.ignore ? [...DEFAULT_ASSET_IGNORE, ...entry.ignore] : DEFAULT_ASSET_IGNORE, + expandDirectories: false, + ignore: normalizeAssetIgnorePatterns(entry.ignore), followSymbolicLinks: entry.followSymlinks ?? false, }); From eded65ebe4d61446e22ffd7dc0defeebc10fe1bb Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:54:23 +0000 Subject: [PATCH 2/2] fixup! fix(@angular/build): avoid expanding directory patterns in asset globs --- packages/angular/build/src/utils/resolve-assets.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/angular/build/src/utils/resolve-assets.ts b/packages/angular/build/src/utils/resolve-assets.ts index 1e7aa83922f8..d7990288b603 100644 --- a/packages/angular/build/src/utils/resolve-assets.ts +++ b/packages/angular/build/src/utils/resolve-assets.ts @@ -17,7 +17,7 @@ const DEFAULT_ASSET_IGNORE_FILES: ReadonlySet = new Set([ '.DS_Store', 'Thumbs.db', ]); -const DEFAULT_ASSET_IGNORE_PATTERNS: string[] = Array.from( +const DEFAULT_ASSET_IGNORE_PATTERNS: readonly string[] = Array.from( DEFAULT_ASSET_IGNORE_FILES, (file) => `**/${file}`, ); @@ -62,7 +62,7 @@ function normalizeIgnorePattern(pattern: string): string[] { */ export function normalizeAssetIgnorePatterns(ignore?: readonly string[]): string[] { if (!ignore?.length) { - return DEFAULT_ASSET_IGNORE_PATTERNS; + return [...DEFAULT_ASSET_IGNORE_PATTERNS]; } return [