Skip to content

perf: match files with a compiled picomatch and walk with tinyglobby - #316

Merged
alexander-akait merged 2 commits into
mainfrom
perf/glob-dependencies
Sep 8, 2026
Merged

perf: match files with a compiled picomatch and walk with tinyglobby#316
alexander-akait merged 2 commits into
mainfrom
perf/glob-dependencies

Conversation

@alexander-akait

@alexander-akait alexander-akait commented Sep 8, 2026

Copy link
Copy Markdown
Member

Summary

globby walked the file system, micromatch matched a path against the patterns, and normalize-path turned the separators around — three packages, three jobs, not duplicates. But between them they carried fast-glob, braces and a second picomatch, and our globby was four majors behind the one stylelint already ships.

tinyglobby walks with fdir, picomatch is the matcher micromatch wraps, and the separator swap is a replaceAll — so nothing new enters the tree and one package leaves it entirely. The production dependency tree drops from 66 packages to 43.

The larger win is not the package count. micromatch.isMatch(file, patterns, options) compiles the patterns on every call, and the plugin calls it per module per check from succeedModule. Compiling once in resolveCheck, where the patterns are already built, is 43× cheaper per call:

before after
matching one path micromatch.isMatch 0.0043 ms compiled picomatch 0.0001 ms
walking the file system globby.sync 0.55 ms tinyglobby.globSync 0.29 ms

Best of five over the test/stylelint/fixtures tree; the walk runs once per build, the match once per module per check.

Node's own built-ins were measured first and rejected. At our >= 22.12 floor both fs.globSync and path.matchesGlob exist and would cost zero dependencies, but they are slower than what they would replace — 1.18 ms and 0.0223 ms against the numbers above — and neither takes a dot option, so a hidden directory reached by a wildcard would silently stop being linted. tinyglobby and picomatch both take it, so that behaviour is unchanged.

normalize-path was called on one value: the result of path.resolve, which has already collapsed the duplicate separators and the trailing slash that package exists to remove. What was left of it there is the separator swap. It was also wrong twice on the shapes resolve returns on Windows, and both were measured before the swap:

input normalize-path now
\\server\share\a.css /server/share/a.css — no longer the share //server/share/a.css
C:\ C: — drive-relative, not the root C:/

A unit test pins all four shapes.

Output was compared against globby before the swap over the plugin's real pattern shapes — a directory, a bare file path, several patterns at once, one matching nothing, and a symlinked directory — and is identical in every case.

What kind of change does this PR introduce?

perf.

Did you add tests for your changes?

Yes, a toPosixPath case in test/utils.test.js covering the POSIX, Windows, UNC and drive-root shapes. The glob swap has identical output and is covered by the existing suite — exclude, ignore, context, resource-query, lint-dirty-modules-only and both stylelint file-walking suites all exercise those paths. 122 passing.

Does this PR introduce a breaking change?

No. dot is preserved, the matched and walked file sets were verified identical, and the two path shapes that change were previously resolved to a different location than the one named.

If relevant, what needs to be documented once your changes are merged or what have you already documented?

n/a — no user-facing option changes. The changeset is a patch.

Use of AI

Written with Claude Code, driven interactively. I asked whether two glob packages were really needed, pointed it at tinyglobby, and then asked for normalize-path to go too. It measured rather than assumed: it benchmarked tinyglobby, Node's built-in fs.globSync/path.matchesGlob and a hand-rolled readdir walk against the current code, found the built-ins slower and lacking dot, and found on the way that the per-module matcher was recompiling its patterns on every call — which is the bigger part of this change. It diffed the file sets over the plugin's real pattern shapes before swapping, and diffed normalize-path against a plain separator swap over every shape path.resolve can return, which is how the two wrong ones turned up. I reviewed the result.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GzZci4NQeiqwdrVfd7dGXy

`globby` walked the file system and `micromatch` matched a path against the
patterns — two packages for two jobs, but between them they carried
`fast-glob`, `braces` and a second `picomatch`, and our `globby` was four
majors behind the one `stylelint` already ships.

`tinyglobby` walks with `fdir`, and `picomatch` is the matcher `micromatch`
wraps, so neither is a new name in the tree. The production dependency tree
drops from 66 packages to 44, and walking the file system is measurably
faster (0.29 ms against 0.55 ms over the stylelint fixtures, best of five).

The larger win is not the package count. `micromatch.isMatch(file, patterns)`
compiles the patterns on every call, and the plugin calls it per module per
check from `succeedModule`. Compiling once in `resolveCheck`, where the
patterns are built, costs 0.0001 ms against 0.0043 ms a call.

`dot` is kept on both, so a hidden directory reached by a wildcard is still
linted; Node's own `fs.globSync` and `path.matchesGlob` were measured first
and are both slower than what they would replace, and neither takes the
option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzZci4NQeiqwdrVfd7dGXy
@socket-security

socket-security Bot commented Sep 8, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​types/​picomatch@​4.0.31001007381100

View full report

`normalize-path` was called on one value, the result of `path.resolve`, which
has already collapsed the duplicate separators and the trailing slash it
exists to remove. What is left of it there is the separator swap, which is a
`replaceAll`.

It is also wrong twice on the shapes `resolve` returns on Windows, and both
were measured before the swap: `\\server\share\a.css` came back as
`/server/share/a.css`, no longer naming the share, and the drive root `C:\`
came back as `C:`, which names whatever the drive's working directory is.
Both keep their shape now, and a unit test pins all four shapes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzZci4NQeiqwdrVfd7dGXy
@alexander-akait
alexander-akait merged commit 9fd820d into main Sep 8, 2026
13 checks passed
@alexander-akait
alexander-akait deleted the perf/glob-dependencies branch September 8, 2026 09:58
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.

1 participant