fix(cli): glob patterns ignored in chokidar v4#1131
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main aws/aws-cdk-cli#1131 +/- ##
==========================================
+ Coverage 87.70% 87.75% +0.05%
==========================================
Files 72 73 +1
Lines 10116 10228 +112
Branches 1337 1371 +34
==========================================
+ Hits 8872 8976 +104
- Misses 1218 1226 +8
Partials 26 26
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR addresses a regression in cdk watch after upgrading to chokidar v4 by translating CLI watch.include/watch.exclude glob patterns into chokidar v4-compatible inputs (non-glob watch paths + an ignored callback), and updating tests accordingly.
Changes:
- Added
convertGlobsToChokidarV4()to derivewatchPathsand anignored()function from include/exclude patterns. - Updated
CdkToolkit.watch()to callchokidar.watch(watchPaths, { ignored, cwd })instead of passing globs directly. - Added new unit tests for glob conversion and adjusted existing watch tests to assert function-based ignore behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| packages/aws-cdk/lib/cli/convert-globs.ts | New glob-to-chokidar-v4 conversion logic (watch paths extraction + ignore callback). |
| packages/aws-cdk/lib/cli/cdk-toolkit.ts | Uses the converter for cdk watch and adds a pre-ready logging skip based on ignored(). |
| packages/aws-cdk/test/cli/convert-globs.test.ts | New tests for the conversion helper. |
| packages/aws-cdk/test/cli/cdk-toolkit.test.ts | Updates watch tests to assert against watchPaths + ignored function behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (pattern === '**') return '.'; | ||
| if (pattern.startsWith('**/')) return '.'; | ||
| // Remove glob parts: ./dir/**/* -> ./dir, src/** -> src | ||
| return pattern.replace(/\/\*\*.*$/, '').replace(/\/\*\.[^/]*$/, '') || '.'; |
There was a problem hiding this comment.
extractWatchPath() can return glob-containing paths (e.g., patterns like *.ts or src/* will not be stripped), which would be passed directly to chokidar.watch() even though v4 no longer supports globs. Consider hardening this to always return a real directory (typically . or a parent dir) for any pattern containing */?/[], using a glob-parent style algorithm (or an existing helper) so watch paths are never globs.
| if (pattern === '**') return '.'; | |
| if (pattern.startsWith('**/')) return '.'; | |
| // Remove glob parts: ./dir/**/* -> ./dir, src/** -> src | |
| return pattern.replace(/\/\*\*.*$/, '').replace(/\/\*\.[^/]*$/, '') || '.'; | |
| if (!pattern) return '.'; | |
| // Pure recursive patterns watch from current directory | |
| if (pattern === '**') return '.'; | |
| if (pattern.startsWith('**/')) return '.'; | |
| // If there are no glob characters, the pattern itself is a concrete path | |
| const firstGlobIndex = pattern.search(/[*?\[\]]/); | |
| if (firstGlobIndex === -1) { | |
| return pattern; | |
| } | |
| // Find the last path separator before the first glob character | |
| const lastSlashBeforeGlob = pattern.lastIndexOf('/', firstGlobIndex); | |
| // Glob in the first segment (e.g., "*.ts", "**/*.ts") => watch current directory | |
| if (lastSlashBeforeGlob === -1) { | |
| return '.'; | |
| } | |
| const baseDir = pattern.slice(0, lastSlashBeforeGlob); | |
| // If baseDir is empty (e.g., "*/foo"), fall back to current directory | |
| return baseDir || '.'; |
| function matchesIncludePattern(normalized: string, basename: string, pathParts: string[], pattern: string): boolean { | ||
| // Non-glob patterns: match if path starts with the pattern | ||
| if (!pattern.includes('*')) { | ||
| return normalized.startsWith(pattern + '/') || normalized === pattern; | ||
| } | ||
| // Glob patterns | ||
| if (pattern === '**') return true; | ||
| if (pattern.startsWith('**/*.')) { | ||
| return basename.endsWith(pattern.slice(4)); | ||
| } | ||
| if (pattern.endsWith('/**/*')) { | ||
| const dir = pattern.slice(0, -5); | ||
| return !dir || normalized.startsWith(dir + '/'); | ||
| } | ||
| if (pattern.startsWith('**/') && pattern.endsWith('/*')) { | ||
| // Match files directly in a directory anywhere (e.g., **/my-dir2/*) | ||
| const dirName = pattern.slice(3, -2); | ||
| return pathParts.includes(dirName); | ||
| } | ||
| return true; | ||
| } |
There was a problem hiding this comment.
matchesIncludePattern() returns true for any unhandled glob shape (line 38), which effectively disables include filtering for many valid patterns. For example, patterns like src/** or cdk*.json-style globs aren’t meaningfully evaluated here; and **/my-dir/* is treated as matching any depth under my-dir (since it only checks pathParts.includes(dirName)). To preserve the CLI’s documented glob support, it would be more reliable to evaluate includes via minimatch (already a dependency) against the normalized relative path and only fall back to permissive behavior when explicitly intended.
| function matchesExcludePattern(normalized: string, basename: string, pathParts: string[], pattern: string): boolean { | ||
| if (!pattern.includes('*')) return basename === pattern; | ||
| if (pattern.startsWith('**/*.')) return basename.endsWith(pattern.slice(4)); | ||
| if (pattern === '**/.*') return basename.startsWith('.'); | ||
| if (pattern === '**/.*/**') { | ||
| // Match files inside hidden directories | ||
| return pathParts.some(part => part.startsWith('.')); | ||
| } | ||
| if (pattern.startsWith('**/') && pattern.endsWith('/**')) { | ||
| return pathParts.includes(pattern.slice(3, -3)); | ||
| } | ||
| if (pattern.startsWith('**/')) { | ||
| // Match directory name anywhere in path (e.g., **/my-dir2) | ||
| const dirName = pattern.slice(3); | ||
| return pathParts.includes(dirName); | ||
| } | ||
| if (pattern.endsWith('/**')) { | ||
| const dir = pattern.slice(0, -3); | ||
| return normalized === dir || normalized.startsWith(dir + '/'); | ||
| } | ||
| if (pattern.endsWith('*')) return basename.startsWith(pattern.slice(0, -1)); | ||
| return false; | ||
| } |
There was a problem hiding this comment.
matchesExcludePattern() does not correctly implement several exclude patterns that are used in repo templates/docs, because it (a) treats non-glob patterns as basename === pattern (so src/test / target won’t exclude a directory tree) and (b) doesn’t handle common globs like target/* or cdk*.json (a * in the middle). This means user-provided excludes may silently stop working. Suggest using minimatch for exclude matching (with appropriate options like dot) against the normalized path, and treating non-glob patterns as path prefixes (relative to cdk.json) rather than basenames.
| // Check include patterns - if file doesn't match any include, ignore it | ||
| if (includePatterns.length > 0 && stats?.isFile()) { | ||
| const matchesInclude = includePatterns.some(pattern => | ||
| matchesIncludePattern(normalized, basename, pathParts, pattern), | ||
| ); | ||
| if (!matchesInclude) return true; | ||
| } |
There was a problem hiding this comment.
Include filtering is currently gated on stats?.isFile() (line 91). Since stats is optional in chokidar’s ignored callback, this can cause include patterns like **/*.js to be ignored entirely when chokidar calls ignored() without stats, leading to deployments being triggered by files outside the include set. Consider making include evaluation independent of stats (while still ensuring directories aren’t excluded prematurely), or configuring chokidar so stats is always available for ignore evaluation.
| * ignored function that evaluates both include and exclude patterns. | ||
| * | ||
| * @param includePatterns - Array of glob patterns to include (e.g., ['**', 'src/*.ts']) | ||
| * @param excludePatterns - Array of glob patterns to exclude (e.g., ['node_modules', '**\/*.d.ts']) |
There was a problem hiding this comment.
JSDoc example for excludePatterns contains an escaped glob **\/*.d.ts (line 76). In a comment this escape isn’t needed and reads like a literal backslash in the pattern; consider updating it to **/*.d.ts to avoid confusion about the expected input format.
| * @param excludePatterns - Array of glob patterns to exclude (e.g., ['node_modules', '**\/*.d.ts']) | |
| * @param excludePatterns - Array of glob patterns to exclude (e.g., ['node_modules', '**/*.d.ts']) |
| // Skip logging for ignored files during initial scan | ||
| if (filePath && ignored(filePath)) { | ||
| return; |
There was a problem hiding this comment.
In the watch event handler, ignored(filePath) is called without a stats argument. With the current ignored implementation, this means include-based filtering (which depends on stats?.isFile()) won’t be applied here, so the “Skip logging for ignored files” behavior may diverge from what chokidar actually ignores/doesn’t ignore. If include filtering continues to depend on stats, consider either passing/deriving stats consistently, or adjusting ignored so it can make the same decision with only the path.
| const watchPaths = includePatterns.map(extractWatchPath); | ||
|
|
||
| const ignored = (path: string, stats?: any): boolean => { | ||
| const normalized = path.replace(/\\/g, '/'); | ||
| const basename = path.split(/[/\\]/).pop() || ''; | ||
| const pathParts = normalized.split('/'); | ||
|
|
There was a problem hiding this comment.
Patterns themselves aren’t normalized before matching (e.g., a user include like ./dir/**/* will produce a watchPath of ./dir, but event paths under cwd are typically dir/..., so include/exclude comparisons can fail due to the leading ./). Consider normalizing includePatterns/excludePatterns up-front (strip leading ./, convert \\ to /) so matching semantics are consistent across platforms and with chokidar’s cwd behavior.
| import { convertGlobsToChokidarV4 } from '../../lib/cli/convert-globs'; | ||
|
|
||
| describe('convertGlobsToChokidarV4', () => { | ||
| describe('watchPaths extraction', () => { | ||
| test('converts "**" to current directory', () => { | ||
| const { watchPaths } = convertGlobsToChokidarV4(['**'], []); | ||
| expect(watchPaths).toEqual(['.']); | ||
| }); | ||
|
|
||
| test('converts "**/*.js" to current directory', () => { | ||
| const { watchPaths } = convertGlobsToChokidarV4(['**/*.js'], []); | ||
| expect(watchPaths).toEqual(['.']); | ||
| }); | ||
|
|
||
| test('converts "src/*.ts" to src directory', () => { | ||
| const { watchPaths } = convertGlobsToChokidarV4(['src/*.ts'], []); | ||
| expect(watchPaths).toEqual(['src']); | ||
| }); | ||
|
|
||
| test('converts "./dir/**/*" to ./dir', () => { | ||
| const { watchPaths } = convertGlobsToChokidarV4(['./dir/**/*'], []); | ||
| expect(watchPaths).toEqual(['./dir']); | ||
| }); | ||
|
|
||
| test('handles multiple include patterns', () => { | ||
| const { watchPaths } = convertGlobsToChokidarV4(['src/**', 'lib/**'], []); | ||
| expect(watchPaths).toEqual(['src', 'lib']); | ||
| }); | ||
|
|
||
| test('deduplicates identical paths', () => { | ||
| const { watchPaths } = convertGlobsToChokidarV4(['**/*.js', '**/*.ts'], []); | ||
| expect(watchPaths).toEqual(['.']); | ||
| }); | ||
| }); | ||
|
|
There was a problem hiding this comment.
Test coverage here doesn’t cover several glob shapes that CDK documents/ships in templates (e.g., target/*, cdk*.json, src/test, *.ts, src/*, and patterns with a leading ./). Adding cases for these would help ensure the conversion preserves existing watch.exclude/watch.include behavior across common configurations, especially since the matcher is currently shape-specific.
| if (pattern === '**') return '.'; | ||
| if (pattern.startsWith('**/')) return '.'; |
There was a problem hiding this comment.
Why not do: if (pattern.startsWith('**')) return '.'; to cover both cases?
Reverts #1131 There are additional glob patterns that the PR does not catch. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
Fixes #1146.
In CDK CLI v2.1103.0, we released #1059 which migrated our chokidar dependency from v3 to v4. We missed a breaking change in this migration: glob is no longer supported. This causes
cdk watchin CDK CLI v2.1102.0 - v2.1105.0 to work improperly withincludeorexcludeparameters that utilize globs -- including the default configuration that isinclude: ['**']. Using the defaultincludecausescdk watchto not watch any files at all.Since CDK CLI exposes an
includeandexcludeproperty that users can set in their owncdk.jsonconfiguration, and we support globs, we must continue to do so. Therefore, this PR migrates globs into the new format that chokidar v4 understands - aincludespath adn anignoredfunction.This code was written with the help of AI, including all tests, and also inspired by similar updates to other projects like this one.
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license