Skip to content
Fran Gonzalez
← Back to blog
·Clanker·9 min read

Removing the brace-expansion patch after moving minimatch to v10

A recurring SyntaxError traced to a brace-expansion patch and an ESM export-shape mismatch: how moving minimatch to v10 removed one patch while a minimatch@3 compatibility patch remained necessary.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

A recurring SyntaxError: ... does not provide an export named 'default' was breaking frontend pipelines whenever Renovate touched a security override. The error pointed at a patch we maintained. Moving minimatch to v10 let me delete the patch for one of the two packages it protected. The other patch remains because its consumers still use minimatch 3.

This is a follow-up to the broader Renovate cleanup in Clearing thirty Renovate merge requests in one pass. That post covers the dependency wave; this post examines the export mismatch and its two patching strategies.

Note

The package versions and override ranges below are the values from this incident. The compatibility discussion applies to the pnpm v11 monorepo described here.

The Problem

Two frontend workspaces in a pnpm v11 monorepo carried this override:

# frontend-nuxt/pnpm-workspace.yaml, admin-nuxt/pnpm-workspace.yaml
# GHSA-mh99-v99m-4gvg: DoS via unbounded expansion; only 5.0.8+ is patched.
brace-expansion@<5.0.8: 5.0.8

The patch required the override to remain at 5.0.8, while Renovate was configured to update that value. When Renovate proposed 5.0.8 to 5.0.9, two things broke at once:

  1. The lockfile on those two workspaces went stale (Renovate dropped the regeneration on the patched workspaces and never recovered it).
  2. An unpatched brace-expansion@5.0.9 slipped into the graph, and every ESLint run died with the same export error.

The patch those workspaces shipped, patches/brace-expansion@5.0.8.patch, was keyed to exactly 5.0.8. Bump the override without updating the patch key and the whole coupling comes apart. I needed to understand why the patch existed before I could remove it.

What these two packages actually do

Both packages are used by multiple JavaScript tools and appear in several dependency trees.

minimatch is glob-pattern matching. You give it "src/**/*.js" and a path, it tells you if they match. ESLint uses it to decide which files to lint; glob uses it for filesystem traversal; editorconfig, test-coverage tools, and file-copy helpers all reach for it. Multiple major versions, including 1, 2, 3, 5, 9, and 10, appear in dependency trees because different consumers declare different major ranges.

brace-expansion expands the {a,b} and {1..10} parts of a pattern. minimatch does not implement braces itself; it delegates to brace-expansion. So every consumer of minimatch is, transitively, a consumer of brace-expansion.

The relevant detail: brace-expansion 5.x changed its export shape. Older majors exported a callable default (CommonJS module.exports = expand; ESM default export). The 5.x line exports { expand } as a named export and dropped the callable default. And <5.0.8 carries GHSA-mh99-v99m-4gvg, a regular-expression denial of service. The advisory does not provide a backport for the 1.x or 2.x lines. Consumers requiring the fix therefore need to use the 5.x line.

That sets up the collision.

The mismatch, and why we patched

The security floor brace-expansion@<5.0.8: 5.0.x force-feeds brace-expansion 5.x into every subtree, including the ones pinned to old minimatch. minimatch reads brace-expansion two different ways depending on its own major:

// minimatch@9: ESM, default import (src/index.ts on the 9.x line)
import expand from "brace-expansion";

// minimatch@10: ESM, named import
import { expand } from "brace-expansion";

// minimatch@3: CommonJS, expects a function
var expand = require("brace-expansion");

Against brace-expansion 5.x (named-only { expand }):

  • minimatch@9’s import expand from fails during module loading because brace-expansion does not export default.
  • minimatch@3’s require(...) gets the { expand } object, not a function, so it crashes the first time it tries to call it.

Both crashes are the same root cause: a consumer expecting a callable default meets a package that only ships a named export. The ecosystem had two compatibility approaches, and the repository used both:

Patch A: brace-expansion@5.0.8.patch (frontend-nuxt, admin-nuxt). Re-add the default export to brace-expansion’s own dist, so minimatch@9’s default import resolves:

// dist/esm/index.js
+ export { expand as default };
// dist/commonjs/index.js
+ exports.default = expand;

Patch B: minimatch@3.1.5.patch (root, cms-app). Patch the consumer side instead: make minimatch@3 tolerate either export shape.

// minimatch.js
-var expand = require('brace-expansion')
+var __braceExpansion = require('brace-expansion')
+var expand = typeof __braceExpansion === 'function'
+  ? __braceExpansion
+  : (__braceExpansion.expand || __braceExpansion.default || __braceExpansion)

I used patching as an interim fix because the available ESLint versions pinned minimatch@^3. The patch preserved the brace-expansion security floor. It also coupled the patch key to a value managed by Renovate.

How Renovate is configured, and where it broke

Renovate runs daily against the monorepo and proposes bumps to both package.json and the pnpm-workspace.yaml override blocks. The repo uses a two-tier pnpm layout: one workspace at the root (shared ESLint/Prettier tooling) and standalone workspaces per app (frontend-nuxt, admin-nuxt, web-app, cms-app). Each carries its own lockfile and its own overrides, and preferFrozenLockfile: true means installs skip resolution when the lockfile already satisfies package.json.

Two CI mechanisms matter here:

  • A supply-chain:lockfile-sync job (scripts/check_lockfile_sync.mjs) that fails when an override bump was not reflected into the lockfile.
  • The standard per-app :quality jobs that actually run ESLint, the only place the export error surfaces at runtime.

The failure mode was specific: Renovate bumped the brace-expansion@<5.0.8 override target from 5.0.8 to 5.0.9, but on the two patched workspaces it could not regenerate the lockfile cleanly. I observed this repeatedly; the root workspace regenerated successfully. The sync guard failed, the unpatched 5.0.9 entered the graph, and the next :quality job hit the missing default export.

I considered a renovate.json5 package rule that would disable the npm manager for brace-expansion in pnpm-workspace.yaml. I opened that rule as a merge request and then closed it because it would prevent future updates to the override.

The fix: move to minimatch 10

minimatch@10 uses a named import, which removes the default-export mismatch.

// minimatch@10, src/index.ts
import { expand } from "brace-expansion";

That is natively compatible with brace-expansion 5.x’s named-only exports. No default export is needed, so no patch is needed. The minimatch@10 changelog documents a Node 20+ floor. This repo runs Node 26, so that requirement is satisfied. Upstream had already moved: glob@13 depends on minimatch ^10.2.2, and recent @eslint/config-array depends on ^10.2.4.

I collapsed the per-major overrides onto a single v10 pin across the three frontend workspaces and deleted both brace-expansion@5.0.8.patch files:

# Before: two ranges, one of them the patched cohort
minimatch@>=9 <10: 9.0.9
minimatch@>=10 <11: 10.2.6
brace-expansion@<5.0.8: 5.0.8 # locked to match the patch key

# After: one range, no patch
minimatch@>=9 <11: 10.2.6
brace-expansion@<5.0.8: 5.0.9 # plain security floor, free to move

The brace-expansion override reverts to a plain security floor that Renovate can bump at will, because there is no patch key to desynchronize. A 5.0.9 to 5.0.10 bump is now just a version change, validated by the lockfile-sync guard and the :quality jobs.

One loose end: frontend-nuxt/Dockerfile had an unconditional COPY patches ./patches in its dependency stage. With the patch deleted, patches/ was empty and the build broke. Removing the copy step avoided retaining an empty patch directory with a .gitkeep.

Results

  • Both brace-expansion@5.0.8.patch files deleted; patchedDependencies entries removed.
  • Zero minimatch@9 anywhere in the repo; the @9/@10 ranges collapse onto 10.2.6.
  • brace-expansion no longer depends on a patch key. Future bumps are checked by the lockfile-sync guard and the :quality jobs.
  • Net change across the merge request: 53 insertions, 131 deletions.

The error that started this is gone for the brace-expansion side, and Renovate can do its job there again.

The patch we kept, and why

The other patch, minimatch@3.1.5.patch in the root and cms-app workspaces, stays. A direct major bump would change the matching behavior expected by the current consumers.

I mapped every consumer of minimatch@3.1.5 in the two patched workspaces. The dependency is spread across the whole CommonJS ESLint generation plus some legacy tooling:

  • eslint@9.39.5 core, which hard-pins minimatch@^3.1.5 directly
  • @eslint/config-array@0.21.2 (^3.1.5)
  • @eslint/eslintrc@3.3.6 (^3.1.5)
  • eslint-plugin-import, eslint-plugin-react, eslint-plugin-jsx-a11y (all ^3)
  • glob@7.2.3, test-exclude@6.0.0 (coverage tooling), copyfiles@2.4.1 (file-copy helper)

Why none of those can follow the same fix as the @9 cohort:

  1. brace-expansion cannot be relaxed. The GHSA has no 1.x/2.x backport, so the security floor keeps force-feeding brace-expansion 5.x into the minimatch@3 subtree. minimatch@3’s require('brace-expansion') default-import still breaks without Patch B.
  2. minimatch@3 cannot be force-bumped to @10. minimatch@10 does ship a CommonJS build, so require() resolves mechanically, but v3 and v10 have different matching semantics (stricter defaults, changed ** and Windows handling). Force-feeding ESLint core a major it did not ask for could change which files ESLint matches without changing the configuration syntax. The patch preserves the current consumer version while providing the export fallback.
  3. eslint core pins the floor. eslint@9.39.5 declares minimatch ^3.1.5. There is no eslint 9.x that drops it.

The migration gate is ESLint v10. eslint@10.8.0 moved core to minimatch ^10.2.5. ESLint 9 to 10 is a major migration involving flat-config-only behavior, a Node <20 removal, formatter changes, and API changes. The migration also affects @typescript-eslint, ESLint plugins, and @nuxt/eslint. glob@7, test-exclude@6, and copyfiles@2.4.1 are separate consumers that would need upgrades or replacements.

I kept minimatch@3.1.5.patch for these reasons:

  • The patch targets minimatch@3.1.5. Renovate has no update for the current patched dependency, and a hypothetical 3.1.6 would require a new patch hash in the lockfile-sync guard.
  • It does not entangle with Renovate. The patch reads .expand || .default, so it tolerates any brace-expansion 5.x. The override target moves freely underneath it. This is the exact opposite of the brace-expansion@5.0.8 patch, which broke precisely because its target could not move.
  • It contains one defensive fallback line in the package that expects the callable export, with a comment explaining the compatibility case.

The brace-expansion override can now move without changing a patch key. The minimatch 3 patch remains until the ESLint v10 migration is completed. I opened that migration as a tracked item.

What I’d do differently

The brace-expansion@5.0.8.patch coupled a patch key to a Renovate-managed value. A patchedDependencies entry should be checked against the update bot’s ability to update the patched version. The minimatch 9 consumers could move to v10; the minimatch 3 consumers still require the compatibility patch.

A freeze rule would have prevented the version update without addressing the export compatibility issue.

References

This post was written with AI assistance.