Skip to content

Add Glob module and portable FileSystem.glob - #9438

Open
jpowersdev wants to merge 9 commits into
Effect-TS:mainfrom
jpowersdev:feat/glob
Open

jpowersdev wants to merge 9 commits into
Effect-TS:mainfrom
jpowersdev:feat/glob

Conversation

@jpowersdev

@jpowersdev jpowersdev commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

FileSystem.glob gave different results on each runtime because it wrapped each platform's native glob. On one fixture:

Pattern Node fs.glob Bun Deno expandGlob
Path form relative to root relative absolute
**/*.ts skips dotfiles skips dotfiles includes .hidden/secret.ts
.* .env, .hidden [] .env, .hidden
src/** includes src omits src includes src

There was also no way to match a path against a pattern without touching a file system.

Changes

  • New effect/Glob module (@stability experimental): Glob.make(pattern, { dot }), Glob.match, and Glob.escape. Supports *, ?, bracket classes (negation, ranges, POSIX classes), nested {a,b}, **, trailing / for directories, and \ escapes.
  • FileSystem.make derives glob from readDirectory, stat, and readLink when it is omitted, and the Node and Deno file systems now use that walk instead of their native glob. FileSystem.glob gains a dot option and its JSDoc documents the contract.
  • FileSystem.make accepts an optional readDirectoryEntries (returning the new FileSystem.DirectoryEntry), which Node (readdir with withFileTypes) and Deno (Deno.readDir) provide so the walk takes entry types from the listing instead of a stat per entry.
  • The shared file system conformance suite gains glob cases (testGlob), which also run on Node without typed listings.

Notable decisions

  • Semantics: results are /-separated and relative to root (absolute for absolute patterns), visited depth-first with entries sorted by name. Wildcards skip dotfiles unless the segment starts with . or dot: true; dir/** includes dir; ** does not traverse symbolic links but a link named by another segment is followed; exclude prunes excluded directories; missing or unreadable directories are empty. x/. is the same as x/; . or .. after ** and UNC patterns or paths (//server/share) match nothing. Braces and commas inside [...] are class members. Extended glob syntax and leading ! negation are not interpreted, but ordinary wildcards still apply within them (for example, ?(a) matches x(a)). Malformed syntax matches literally.
  • Robustness: segments compile to tokens matched with single-star backtracking, so matching a name costs at most tokens × characters. Brace expansion is capped at 10,000 alternatives (and a depth of 64), class parsing is linear and shared between brace expansion and segment compilation, and compiling never throws; larger patterns keep their braces literal.
  • Behaviour change for existing users (described in the changeset): \ is now always an escape, Deno no longer returns absolute paths or matches dotfiles under **, and Node-only extglobs no longer expand.
  • Bundle size: FileSystem.make grows by about 2.25 KB; importing only the FileSystem service or makeNoop is unchanged (tree-shaken).

Benchmarks

Median times on macOS, Node 24, warm cache, 10 runs each; all three return identical file sets.

Case Native fs.glob (previous) This PR npm glob@13
**/*.ts in packages/, ~8k files 74 ms 62 ms 28 ms
*/src/**/*.ts in packages/ 2.5 ms 2.0 ms 1.0 ms
effect/src/*.ts 0.31 ms 0.18 ms 0.20 ms
**/package.json in node_modules/.pnpm, ~56k files 526 ms 388 ms 112 ms

File systems built with FileSystem.make without readDirectoryEntries fall back to a stat per candidate entry and are slower (about 2x native on large trees).

Notes

  • pnpm codegen currently fails on main (Effect.ts: missing top-level module JSDoc, since it starts with a triple-slash reference), so the Glob barrel entry in index.ts was added by hand in the generated format.
  • Not tested on Windows; drive roots (C:/) are handled and covered by matcher tests only.
  • Follow-ups: move the internal tool packages off npm glob, and consider a typed listing on the FileSystem service itself.

Closes EFF-2375

@changeset-bot

changeset-bot Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 49f9ed6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 33 packages
Name Type
effect Patch
@effect/platform-node-shared Patch
@effect/platform-deno Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-cloudflare Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/ai-typesafe Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/graphql-generator Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-janitor effect-janitor Bot added enhancement New feature or request 4.0 labels Oct 16, 2026

@effect-janitor effect-janitor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete: three confirmed blocking issues reported. Validated with targeted Node reproductions of the matcher and derived walker; no dependency installation or repository changes retained.

Reviewed at 5fcd726: 3 blocking, 0 nits.

Disagree? Reply in the finding's thread, or a maintainer can comment /janitor to re-run or dismiss this review.

Comment thread packages/effect/src/internal/glob.ts Outdated
Comment on lines +250 to +253
} else if (char === "?") {
magic = true
source += "[^/]"
} else if (char === "[") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Each * becomes another greedy regex quantifier, so matching has exponential backtracking for ordinary valid patterns. I reproduced match(compile("*a".repeat(14) + "b", false), "a".repeat(28)) taking about 1.4 seconds; increasing to 16 exceeded a 10-second check. These are short patterns and valid filename lengths, and the same regex runs synchronously inside the filesystem walk, so a user-supplied pattern can block the runtime. Please use bounded/non-backtracking segment matching (or otherwise prevent this combinatorial search), with an adversarial matching regression test.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed in b91e486.

Replaced segment regex backtracking with bounded token matching and added an adversarial regression test; the extracted matcher completes the reproduction promptly.

Comment thread packages/effect/src/internal/glob.ts Outdated
Comment on lines +198 to +200
}
out.push(segment)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dropping . segments before tracking their position relative to ** violates the new documented contract that . or .. after ** match nothing. For example, **/. is compiled as **, and a walk over a root containing only the file a.ts returns ["a.ts"]; it should return no matches under that contract (and must not treat a file as having a . child). Preserve/reject these segments after a globstar rather than normalizing them away, and cover both the matcher and walker.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed in b91e486.

Compilation now rejects dot and dot-dot segments after a globstar before normalization; matcher and shared walker regressions were added.

Comment on lines +81 to +87
i++
} else if (char === "{") {
stack.push({ open: i, commas: [] })
} else if (char === ",") {
stack[stack.length - 1]?.commas.push(i)
} else if (char === "}") {
const group = stack.pop()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Brace expansion scans inside bracket classes, where braces and commas are literal class members. Consequently [{a,b}] expands to [a] and [b], and rejects the single-character paths {, ,, and } even though they are members of the specified class. This also affects FileSystem.glob for filenames containing these characters. Make the brace scanner respect bracket-class boundaries (including escaped/initial closing brackets), and add a combined class/brace regression test.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed in b91e486.

Brace scanning now skips bracket classes, including initial closing brackets, and combined class/brace regressions were added. Confirmed literal brace and comma members match.

@github-actions

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 39.02 KB 39.02 KB 0.00 KB (0.00%)
basic.ts 7.02 KB 7.02 KB 0.00 KB (0.00%)
batching.ts 10.73 KB 10.73 KB 0.00 KB (0.00%)
brand.ts 6.71 KB 6.71 KB 0.00 KB (0.00%)
cache.ts 11.26 KB 11.26 KB 0.00 KB (0.00%)
config.ts 22.44 KB 22.44 KB 0.00 KB (0.00%)
differ.ts 21.54 KB 21.54 KB 0.00 KB (0.00%)
http-client.ts 23.93 KB 23.93 KB 0.00 KB (0.00%)
http-router.ts 35.48 KB 35.48 KB 0.00 KB (0.00%)
logger.ts 11.60 KB 11.60 KB 0.00 KB (0.00%)
metric.ts 9.08 KB 9.08 KB 0.00 KB (0.00%)
optic.ts 6.93 KB 6.93 KB 0.00 KB (0.00%)
pubsub.ts 15.68 KB 15.68 KB 0.00 KB (0.00%)
queue.ts 12.52 KB 12.52 KB 0.00 KB (0.00%)
schedule.ts 11.73 KB 11.73 KB 0.00 KB (0.00%)
schema-bigdecimal.ts 13.80 KB 13.80 KB 0.00 KB (0.00%)
schema-binary.ts 40.48 KB 40.48 KB 0.00 KB (0.00%)
schema-class.ts 21.22 KB 21.22 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 32.31 KB 32.31 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 27.50 KB 27.50 KB 0.00 KB (0.00%)
schema-string-transformation.ts 14.95 KB 14.95 KB 0.00 KB (0.00%)
schema-string.ts 12.05 KB 12.05 KB 0.00 KB (0.00%)
schema-template-literal.ts 16.21 KB 16.21 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 38.54 KB 38.54 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 25.60 KB 25.60 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 20.47 KB 20.47 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 20.67 KB 20.67 KB 0.00 KB (0.00%)
schema-toFormatter.ts 20.77 KB 20.77 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 25.40 KB 25.40 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 20.76 KB 20.76 KB 0.00 KB (0.00%)
schema.ts 20.41 KB 20.41 KB 0.00 KB (0.00%)
stm.ts 13.79 KB 13.79 KB 0.00 KB (0.00%)
stream.ts 10.05 KB 10.05 KB 0.00 KB (0.00%)

@effect-janitor effect-janitor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. All three earlier blocking threads still apply, and one additional failing regression case was confirmed. Validation used the current dependency-free compiler/matcher extracted into a temporary TypeScript harness; the harness has been removed.

Reviewed at eca5178: 1 blocking, 0 nits.

Disagree? Reply in the finding's thread, or a maintainer can comment /janitor to re-run or dismiss this review.

{ pattern: "**/..", matches: [], rejects: ["..", "a/.."] },
{ pattern: "/abs/*.ts", matches: ["/abs/a.ts"], rejects: ["abs/a.ts"] },
{ pattern: "C:/x/*", matches: ["C:/x/a"], rejects: ["x/a"] },
{ pattern: "//server/share/*", matches: [], rejects: ["//server/share/a", "/server/share/a"] },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This new UNC case fails with the current implementation: match(compile("//server/share/*", false), "/server/share/a") returns true (and so does matching //server/share/a). normalizeSegments collapses the second leading empty segment in both patterns and paths, making this an ordinary absolute pattern rather than rejecting it. Please implement the intended UNC rejection or reconcile the expectation with the intended semantics; as written the new matcher suite cannot pass.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed in b91e486.

Patterns and paths now reject UNC forms explicitly. Confirmed the original reproduction returns false.

@effect-janitor effect-janitor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All four earlier blocking findings are addressed. No new merge-blocking issues found. Validated the updated matcher with the existing Glob test cases and prior reproductions using a dependency-free extracted-source harness; the full Vitest and filesystem suites were not run.

Reviewed at b91e486: no new findings.

Comment thread packages/effect/src/internal/glob.ts Outdated
Comment on lines +250 to +253
} else if (char === "?") {
magic = true
source += "[^/]"
} else if (char === "[") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed in b91e486.

Replaced segment regex backtracking with bounded token matching and added an adversarial regression test; the extracted matcher completes the reproduction promptly.

Comment thread packages/effect/src/internal/glob.ts Outdated
Comment on lines +198 to +200
}
out.push(segment)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed in b91e486.

Compilation now rejects dot and dot-dot segments after a globstar before normalization; matcher and shared walker regressions were added.

Comment on lines +81 to +87
i++
} else if (char === "{") {
stack.push({ open: i, commas: [] })
} else if (char === ",") {
stack[stack.length - 1]?.commas.push(i)
} else if (char === "}") {
const group = stack.pop()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed in b91e486.

Brace scanning now skips bracket classes, including initial closing brackets, and combined class/brace regressions were added. Confirmed literal brace and comma members match.

{ pattern: "**/..", matches: [], rejects: ["..", "a/.."] },
{ pattern: "/abs/*.ts", matches: ["/abs/a.ts"], rejects: ["abs/a.ts"] },
{ pattern: "C:/x/*", matches: ["C:/x/a"], rejects: ["x/a"] },
{ pattern: "//server/share/*", matches: [], rejects: ["//server/share/a", "/server/share/a"] },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed in b91e486.

Patterns and paths now reject UNC forms explicitly. Confirmed the original reproduction returns false.

@effect-janitor
effect-janitor Bot dismissed stale reviews from themself October 16, 2026 21:31

Dismissed by janitor: no blocking finding stands at b91e486.

…pansion

Replaces the regex segment and class matchers with code point tokens and
range lists, and uses one class parser for both brace expansion and segment
compilation. `x/.` now requires `x` to be a directory, internal helpers are
no longer exported, `Glob.isGlob` is removed, and the Bun positional write
workaround is reverted so it can land separately.
@effect-bot

Copy link
Copy Markdown
Contributor

@jpowersdev Heads-up before independent review: the compiler simplification in fe4cff2 was rebased on top of your fixes (4c48a81 and b91e486), not force-pushed over them. It retains your UNC path rejection and triple-slash absolute-path distinction, while replacing per-class regexes and the separate class scanner with code-point tokens and one shared class parser for brace expansion and segment compilation. This keeps class parsing consistent and bounds wildcard matching without regex backtracking.

I have now pushed tests-only coverage for UNC paths and directory x/.; matcher and shared walker suites pass on Node (typed listings and stat fallback) and Deno. The earlier clean review predates the compiler rewrite; independent review of the current head is still next. Thank you for the fixes!

@effect-janitor effect-janitor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the updated implementation and marked all four earlier threads addressed. Reported two blocking error-propagation issues, confirmed with targeted runtime reproductions. Full targeted test execution was unavailable because frozen dependency installation failed with a registry 403.

Reviewed at 7e5d56e: 2 blocking, 0 nits.

Disagree? Reply in the finding's thread, or a maintainer can comment /janitor to re-run or dismiss this review.

Comment thread packages/effect/src/internal/glob.ts Outdated
Comment on lines +623 to +624
const exists = (relative: string): Effect.Effect<boolean> =>
Effect.map(Effect.exit(fs.stat(toFsPath(relative))), (exit) => exit._tag === "Success")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The synthetic-entry existence probe turns every failed Exit into false, including unexpected filesystem errors and defects. I verified that globbing . with stat failing with Unknown, or dying with "boom", succeeds with [] instead of propagating the failure. This also makes the newly added probing-error and probing-defect tests in FileSystem.test.ts fail. Recover only the intended missing/unreadable cases and preserve other errors and causes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed in 49f9ed6.

Addressed by matchEffect with selective recovery; targeted checks confirm unexpected stat errors and defects propagate.

Comment thread packages/effect/src/internal/glob.ts Outdated
Comment on lines +682 to +685
return type === "Unknown"
? Effect.map(
Effect.exit(fs.readLink(toFsPath(relative))),
(exit) => ({ relative, accepted, next: exit._tag === "Success" ? followed : next })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Every failed readLink Exit is treated as evidence that the entry is not a symlink, including unexpected errors and defects. With an untyped listing, a directory whose readLink fails with Busy still produces a successful glob result; Effect.die is swallowed as well. Besides failing the new readLink error/defect tests, this can traverse a symlink when link detection fails unexpectedly, undermining globstar cycle prevention. Distinguish the expected not-a-link errors from unexpected failures and propagate the latter, including defects.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed in 49f9ed6.

Addressed by selective readLink recovery; targeted checks confirm Busy failures and defects propagate.

@effect-janitor effect-janitor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All six earlier findings are addressed. Targeted Node checks passed for bounded wildcard matching, globstar dot rejection, class/brace parsing, UNC rejection, and propagation of filesystem errors and defects. No new blocking findings; diff whitespace checks passed.

Reviewed at 49f9ed6: no new findings.

Comment thread packages/effect/src/internal/glob.ts Outdated
Comment on lines +623 to +624
const exists = (relative: string): Effect.Effect<boolean> =>
Effect.map(Effect.exit(fs.stat(toFsPath(relative))), (exit) => exit._tag === "Success")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed in 49f9ed6.

Addressed by matchEffect with selective recovery; targeted checks confirm unexpected stat errors and defects propagate.

Comment thread packages/effect/src/internal/glob.ts Outdated
Comment on lines +682 to +685
return type === "Unknown"
? Effect.map(
Effect.exit(fs.readLink(toFsPath(relative))),
(exit) => ({ relative, accepted, next: exit._tag === "Success" ? followed : next })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Addressed in 49f9ed6.

Addressed by selective readLink recovery; targeted checks confirm Busy failures and defects propagate.

@effect-janitor
effect-janitor Bot dismissed their stale review October 16, 2026 21:57

Dismissed by janitor: no blocking finding stands at 49f9ed6.

This branch is waiting to be deployed

1 waiting deployment
fork — 49f9ed60 Waiting Oct 16, 2026 by effect-bot via approval-gate #20579
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants