Skip to content

fix(esbuild): honor package.patterns negations in packaged artifacts - #13795

Open
czubocha wants to merge 2 commits into
mainfrom
fix/esbuild-package-patterns-negations
Open

fix(esbuild): honor package.patterns negations in packaged artifacts#13795
czubocha wants to merge 2 commits into
mainfrom
fix/esbuild-package-patterns-negations

Conversation

@czubocha

@czubocha czubocha commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Honor package.patterns negation patterns (!...) when the built-in esbuild build packages function artifacts — both with package.individually (per-function zips) and for combined-service packaging
  • Function-level patterns are merged after service-level patterns (function wins on conflict), matching the merge order documented for classic packaging; without individually, function-level patterns are ignored, consistent with classic packaging, which already warns about that combination
  • Fully excluded node_modules subtrees leave no empty directory entries in the zip
  • The compiled handler bundle, its sourcemap (when emitted), and package.json/lockfile are always packaged and cannot be excluded via patterns
  • Observability: a per-artifact Excluded <N> entries from <zip> via package.patterns info line (visible with --verbose), a once-per-run warning when patterns exclude everything under node_modules while packages: external is active, and debug traces of the merged pattern list and included/excluded entry counts
  • New pure helper packages/serverless/lib/utils/package-patterns.js mirrors classic packaging's ordered last-match-wins pattern semantics; a parity test suite runs the helper and the real classic resolveFilePathsFromPatterns against the same fixture trees and fails CI on any drift
  • Docs: docs/sf/providers/aws/guide/building.md gains a "Packaging Patterns" section stating which parts of an esbuild artifact patterns apply to
  • Classic (non-esbuild) packaging source is untouched; artifacts for configs without negation patterns remain content-identical

Root cause

With build.esbuild active, package.patterns were only used as a positive glob to add extra files from the source directory. Negation patterns match nothing in a positive glob, so they were silently ignored, and the entire shared .serverless/build/node_modules tree was copied into every function artifact via zip.directory() with no filtering. This contradicted the packaging docs ("Packaging functions separately"), which state that per-function patterns merge with service-wide patterns and support ! exclusions.

The fix applies the ordered pattern toggle (last match wins, same semantics as classic packaging) as a predicate over the deterministic sorted entry walk introduced in #13794, and runs the additional-include list through the same compiled toggle, so a function-level negation can also drop a service-level include. Filtering happens after the sort, so entry-order determinism is preserved (pinned by 6-run hash-stability tests with filtering active). Patterns cannot suppress the fail-fast contract: an unreadable file under node_modules still fails packaging with CANNOT_READ_FILE even when patterns exclude it (test-enforced on both packaging paths).

Test plan

  • 61 new unit tests across 3 suites: helper semantics (package-patterns.test.js), classic-engine parity (package-patterns-parity.test.js), and artifact filtering incl. whole-zip sha256 identity for pattern-less configs, 6-run hash stability with filtering active, directory-entry pruning, warning dedupe, unreadable-entry fail-fast, and _packageAll (package-patterns-filtering.test.js)
  • Full unit suite green: 191 suites, 3697 tests; all determinism/fail-fast suites from fix(esbuild): make packaged zip entry order deterministic #13794 untouched and passing
  • Live A/B packaging against main: the function with negations loses exactly the 416 targeted entries (2361 → 1945 zip entries), zero collateral; the function without patterns produces a byte-identical artifact (whole-zip sha256 equal between the main CLI and this branch's CLI)
  • Hash stability with negations at scale: a ~2,700-entry service packaged 3× with a !node_modules/<dep>/** negation — hashes constant across runs, entry-list diff shows exactly that dependency's subtree (+ its directory entry) removed and nothing else
  • Exclude-everything warning fires exactly once per run (both functions met the trigger; one line printed), visible without --verbose
  • Lint and Prettier clean on all changed files
  • No classic packaging source file modified (git status on packages/serverless/lib/plugins/package/ clean)

Coverage notes, for reviewer awareness:

  • The parity suite does not cover devDependencyExcludeSet ordering (untested on both engines) or directory-entry pruning (classic packaging enumerates files only, so zip directory entries have no classic counterpart to compare against; the pruning behavior is pinned by dedicated esbuild-side tests instead).
  • The Excluded <N> entries line is emitted at the info level, so it appears only with --verbose — consistent with classic packaging's exclusion logging.
  • Directory includes (a defensively-handled branch that globby's onlyFiles default makes unreachable from user config) intentionally receive no pattern predicate; commented at both call sites.

How to test

Use a project with build.esbuild + packages: external, package.individually: true, and per-function negation patterns:

build:
  esbuild:
    bundle: true
    packages: external

package:
  individually: true

functions:
  function1:
    handler: src/fn1.handler
  function2:
    handler: src/fn2.handler
    package:
      patterns:
        - '!node_modules/fastify/**'
        - '!node_modules/serverless-http/**'

where both functions depend on fastify and serverless-http among other dependencies.

# 1. Bug on the released CLI: negations are silently ignored
rm -rf .serverless
serverless package
unzip -l .serverless/*function2.zip | grep -cE "node_modules/(fastify|serverless-http)/"
# -> 416 (entries the patterns should have excluded are all present)

# 2. Fix on this branch (CLI run from source)
rm -rf .serverless
node <path-to-this-checkout>/packages/sf-core/bin/sf-core.js package
unzip -l .serverless/*function2.zip | grep -cE "node_modules/(fastify|serverless-http)/"
# -> 0
unzip -l .serverless/*function2.zip | grep -E "node_modules/(fastify|serverless-http)/$"
# -> no output: no empty directory entries left behind
unzip -l .serverless/*function1.zip | tail -2
# -> same entry count as before the fix: a function without patterns is untouched

# 3. Exclusion visibility (info level, requires --verbose)
rm -rf .serverless
node <path-to-this-checkout>/packages/sf-core/bin/sf-core.js package --verbose 2>&1 | grep "via package.patterns"
# -> Excluded 416 entries from <service>-function2.zip via package.patterns

# 4. Exclude-everything warning: temporarily add a service-level pattern
#      package:
#        patterns:
#          - '!node_modules/**'
#    and repackage. Exactly one warning prints (no --verbose needed):
#    "package.patterns exclude everything under node_modules, but "packages: external"
#     requires dependencies in the artifact. If dependencies are provided another way
#     (e.g. a Lambda layer) or this exclusion predates build.esbuild, review or remove
#     these patterns."

# 5. Unit tests
npm run test:unit -w @serverless/framework -- package-patterns
# -> 3 suites, 61 tests

Release note callout (behavior-activating fix)

This fix activates configuration that was previously inert. Any existing package.patterns negations in a build.esbuild service — patterns that have had no effect until now — take effect on the next deploy and remove the matched files from the artifacts.

Two configurations deserve explicit attention:

  • Exclude-everything: !node_modules/** combined with packages: external (common in Lambda-layer setups, or carried over from before adopting build.esbuild) now produces artifacts without their dependencies. The new once-per-run warning flags exactly this case.
  • Cherry-picking (activates silently): exclude-everything plus re-includes, e.g. !node_modules/** + node_modules/some-dep/**. Re-include patterns match only the named package's own files — never its transitive dependencies, which live as sibling directories. These are the documented semantics (classic packaging has always worked this way), but the re-include list has not been enforced while build.esbuild ignored patterns, so lists written before that period may no longer cover everything the kept packages need. Verify the list still includes the transitive dependencies of every package you keep, or the function fails at invocation with Cannot find module. No warning covers this case, since partial exclusion is indistinguishable from intended use.

Recommendation: ship in the same minor release as #13794 with a prominent release-note entry asking users to review existing negation patterns under build.esbuild before upgrading. This change adds no independent artifact churn on top of #13794: configurations without negation patterns produce byte-identical artifacts to that base, so the two changes share a single "artifacts re-upload once on the first post-upgrade deploy" story.

Related

Closes #12813
Closes #12742

Summary by CodeRabbit

  • New Features

    • Added support for package.patterns when packaging esbuild artifacts.
    • Patterns can include or exclude dependencies, additional files, and directories, with ordered re-inclusion support.
    • Added documentation explaining how packaging patterns affect build artifacts.
  • Bug Fixes

    • Improved packaging consistency and archive determinism.
    • Preserved symlinks and required build outputs during filtering.
    • Added clearer diagnostics for packaging patterns and missing external dependencies.

@Mmarzex

Mmarzex commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a5e4c03f-42cb-4ef3-9dcf-6bc2b059b9e7

📥 Commits

Reviewing files that changed from the base of the PR and between 90defb8 and d6d28bb.

📒 Files selected for processing (2)
  • packages/serverless/lib/plugins/esbuild/index.js
  • packages/serverless/test/unit/lib/plugins/esbuild/package-patterns-filtering.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/serverless/lib/plugins/esbuild/index.js

📝 Walkthrough

Walkthrough

Changes

Esbuild packaging now supports ordered package.patterns filtering for individual and shared artifacts. The change adds reusable matching utilities, deterministic archive traversal, dependency filtering, diagnostics, warnings, documentation, and tests.

Esbuild package pattern filtering

Layer / File(s) Summary
Package pattern matching utilities
packages/serverless/lib/utils/package-patterns.js, packages/serverless/test/unit/lib/utils/package-patterns.test.js, packages/serverless/test/unit/lib/utils/package-patterns-parity.test.js
Patterns use normalized, ordered last-match-wins matching. Tests cover paths, directories, dotfiles, globstars, batch filtering, and parity with classic packaging.
Deterministic archive entry filtering
packages/serverless/lib/plugins/esbuild/index.js
Archive traversal reuses lstat results, preserves ordering and symlinks, applies filters, and reports unreadable files consistently.
Individual function packaging
packages/serverless/lib/plugins/esbuild/index.js, packages/serverless/test/unit/lib/plugins/esbuild/package-patterns-filtering.test.js
Individual packages merge service and function patterns, filter includes and node_modules, preserve required handler output, and report exclusions.
Shared packaging and diagnostics
packages/serverless/lib/plugins/esbuild/index.js, packages/serverless/test/unit/lib/plugins/esbuild/package-patterns-filtering.test.js, docs/sf/providers/aws/guide/building.md
Shared packages apply service-level patterns, report exclusions and retained dependencies, emit conditional warnings, and document pattern behavior. Tests verify filtering and deterministic artifacts.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EsbuildPackaging
  participant PackagePatterns
  participant Archive
  EsbuildPackaging->>PackagePatterns: Compile service and function patterns
  EsbuildPackaging->>PackagePatterns: Filter includes and node_modules entries
  EsbuildPackaging->>Archive: Append retained entries in sorted order
  Archive-->>EsbuildPackaging: Return packaged artifact and exclusion diagnostics
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: esbuild now honors package.patterns negations in packaged artifacts.
Linked Issues check ✅ Passed The changes apply function-level package.patterns exclusions for esbuild artifacts and preserve required packaging behavior described in issues [#12813] and [#12742].
Out of Scope Changes check ✅ Passed The code, utility, documentation, and test changes directly support esbuild package-pattern filtering and the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/esbuild-package-patterns-negations

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

packages/serverless/lib/plugins/esbuild/index.js

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

packages/serverless/test/unit/lib/plugins/esbuild/package-patterns-filtering.test.js

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
packages/serverless/test/unit/lib/plugins/esbuild/package-patterns-filtering.test.js (1)

32-57: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add cleanup for the temporary service directories.

makeServiceDir and makeRacyServiceDir create directories under os.tmpdir() with mkdtempSync. No test removes them. This file creates more than 30 fixtures, each holding a node_modules tree and generated zip artifacts, so repeated local runs accumulate data.

Track the created paths and remove them in an afterAll hook with fs.rmSync(dir, { recursive: true, force: true }).

♻️ Proposed cleanup helper
+const createdServiceDirs = []
+
+afterAll(() => {
+  for (const dir of createdServiceDirs) {
+    fs.rmSync(dir, { recursive: true, force: true })
+  }
+})
+
 function makeServiceDir({ emptyNodeModules = false } = {}) {
   const serviceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sls-esbuild-pat-'))
+  createdServiceDirs.push(serviceDir)

Also applies to: 93-100

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/serverless/test/unit/lib/plugins/esbuild/package-patterns-filtering.test.js`
around lines 32 - 57, Track every temporary directory returned by makeServiceDir
and makeRacyServiceDir in a shared collection, then add an afterAll cleanup hook
that removes each tracked path with fs.rmSync using recursive and force options.
Ensure all fixture creators register their generated directories while
preserving existing test behavior.
packages/serverless/lib/plugins/esbuild/index.js (3)

1034-1043: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace _.union with a native Set union.

Line 1034 uses _.union. The coding guidelines prefer native JavaScript over lodash.

♻️ Proposed change
-                const unionedIncludes = _.union(
-                  packageIncludes,
-                  functionIncludes,
-                ).sort()
+                const unionedIncludes = [
+                  ...new Set([...packageIncludes, ...functionIncludes]),
+                ].sort()

The filtering and the exclusion arithmetic on lines 1038-1043 are correct.

As per coding guidelines: "Prefer native JavaScript over lodash and use async/await for asynchronous code."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/serverless/lib/plugins/esbuild/index.js` around lines 1034 - 1043,
Replace the lodash _.union call in the unionedIncludes construction with a
native Set-based union, preserving the combined unique values and the existing
sorted array output used by filterPaths and excludedEntryCount.

Source: Coding guidelines


989-1012: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated node_modules entry filter in _package and _packageAll. Both packaging paths declare the same three counters and the same nodeModulesEntryFilter body. The only difference is the compiled pattern source. One shared factory removes the divergence risk.

  • packages/serverless/lib/plugins/esbuild/index.js#L989-L1012: replace the inline counters and filter with a call to a shared createNodeModulesEntryFilter(compiledPatterns) helper, and read the counters from the returned object when calling _reportPatternFiltering.
  • packages/serverless/lib/plugins/esbuild/index.js#L1188-L1211: replace this copy with the same helper call.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/serverless/lib/plugins/esbuild/index.js` around lines 989 - 1012, In
packages/serverless/lib/plugins/esbuild/index.js at lines 989-1012 and
1188-1211, extract the duplicated counters and nodeModulesEntryFilter logic into
a shared createNodeModulesEntryFilter(compiledPatterns) helper. Replace both
inline implementations with calls to that helper, and use its returned counters
when invoking _reportPatternFiltering; only the compiled pattern source should
remain path-specific.

111-116: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider skipping lstat for entries inside an already-excluded directory.

Line 113 runs lstatEntry for every entry before the filter decides. If the patterns exclude a whole subtree, such as !node_modules/**, the walk still performs one syscall per file in that subtree and discards the result. entries is sorted, so a parent directory is always visited before its children. You can record excluded directory prefixes and skip their children before the lstat call.

This is a throughput improvement only. Current behavior is correct.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/serverless/lib/plugins/esbuild/index.js` around lines 111 - 116,
Optimize the entry loop around lstatEntry by tracking directory prefixes
rejected by filter and skipping any later entries beneath those prefixes before
calling lstatEntry. Since entries are sorted, record an excluded entry when its
stats identify a directory, preserve existing filter and appendFileEntry
behavior for other entries, and ensure prefix matching only skips descendants of
the excluded directory.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/serverless/test/unit/lib/plugins/esbuild/package-patterns-filtering.test.js`:
- Line 592: Update both determinism tests in the package-patterns filtering test
suite to pass 60_000 as each test’s third timeout argument, rather than relying
on the file-level jest.setTimeout configuration. Remove or leave the broader
timeout only as appropriate, but ensure each determinism test explicitly uses
the 60-second timeout.

---

Nitpick comments:
In `@packages/serverless/lib/plugins/esbuild/index.js`:
- Around line 1034-1043: Replace the lodash _.union call in the unionedIncludes
construction with a native Set-based union, preserving the combined unique
values and the existing sorted array output used by filterPaths and
excludedEntryCount.
- Around line 989-1012: In packages/serverless/lib/plugins/esbuild/index.js at
lines 989-1012 and 1188-1211, extract the duplicated counters and
nodeModulesEntryFilter logic into a shared
createNodeModulesEntryFilter(compiledPatterns) helper. Replace both inline
implementations with calls to that helper, and use its returned counters when
invoking _reportPatternFiltering; only the compiled pattern source should remain
path-specific.
- Around line 111-116: Optimize the entry loop around lstatEntry by tracking
directory prefixes rejected by filter and skipping any later entries beneath
those prefixes before calling lstatEntry. Since entries are sorted, record an
excluded entry when its stats identify a directory, preserve existing filter and
appendFileEntry behavior for other entries, and ensure prefix matching only
skips descendants of the excluded directory.

In
`@packages/serverless/test/unit/lib/plugins/esbuild/package-patterns-filtering.test.js`:
- Around line 32-57: Track every temporary directory returned by makeServiceDir
and makeRacyServiceDir in a shared collection, then add an afterAll cleanup hook
that removes each tracked path with fs.rmSync using recursive and force options.
Ensure all fixture creators register their generated directories while
preserving existing test behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: da37a188-36e9-44df-9c6a-ab78e22e7f4c

📥 Commits

Reviewing files that changed from the base of the PR and between 52f6e27 and 90defb8.

📒 Files selected for processing (6)
  • docs/sf/providers/aws/guide/building.md
  • packages/serverless/lib/plugins/esbuild/index.js
  • packages/serverless/lib/utils/package-patterns.js
  • packages/serverless/test/unit/lib/plugins/esbuild/package-patterns-filtering.test.js
  • packages/serverless/test/unit/lib/utils/package-patterns-parity.test.js
  • packages/serverless/test/unit/lib/utils/package-patterns.test.js

- Scope the 6-run determinism test timeouts per test (jest.setTimeout is
  file-global regardless of describe placement)
- Extract the duplicated node_modules entry filter into a shared
  createNodeModulesEntryFilter factory so the twin packaging paths cannot drift
- Replace lodash union with a native Set union
- Track and remove temporary fixture directories after the filtering suite
@czubocha

Copy link
Copy Markdown
Contributor Author

Addressed the review in d6d28bb:

  • Determinism test timeouts (actionable): fixed — both 6-run tests now carry an explicit 60_000 third argument; the describe-scoped jest.setTimeout(60_000) is removed with a comment explaining why (file-global semantics).
  • Fixture cleanup (nitpick): fixed — all fixture directories are tracked via a shared makeFixtureDir() and removed in afterAll. Verified: a fresh suite run leaves zero sls-esbuild-pat-* directories behind.
  • Duplicated entry filter (nitpick): fixed — extracted createNodeModulesEntryFilter(compiledPatterns) returning { filter, counters }; both packaging paths consume it, and _reportPatternFiltering reads the returned counters.
  • _.union → native Set (nitpick): fixed.
  • Skip lstat under excluded directory prefixes (nitpick): deliberately not taken. Skipping the stat for entries beneath an excluded directory would make the fail-fast contract depend on package.patterns: an unreadable file under an excluded subtree would be silently skipped instead of failing with CANNOT_READ_FILE. That exact contract is pinned by the "patterns cannot suppress an unreadable entry" tests in this PR, which fail under the proposed reordering. The saved syscalls are ~35ms per ~2,650 entries and only when patterns are active, so correctness wins.

Full suite re-run after the changes: 9 esbuild suites / 69 tests green, lint and Prettier clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Build override package Pattern V4 Build override package Pattern V4

2 participants