src/util/paths.ts — POSIX-path normalisation
Purpose
Section titled “Purpose”Normalize path separators to forward slashes so cache keys are stable across Windows / *nix. Used wherever a path participates in a cache key (input file paths, output file paths, etc.).
Public surface
Section titled “Public surface”export function toPosix(p: string): stringexport function relPosix(from: string, to: string): stringexport function normalizeGlob(glob: string): stringexport function normalizeBunGlob(glob: string): stringexport function staticPrefix(glob: string): stringexport function grantPrefix(glob: string): stringexport function wholeSubtreePrefixes(globs: readonly string[]): string[] | nullexport function asTrees(patterns: readonly string[]): string[]// An output list's positive globs and what its `!` entries take back, and// the matcher that honours both: a `!` glob compiled as-is is Bun.Glob's own// negation, true of every other path (A-44).export function splitNegations(globs: readonly string[]): { positive: string[]; negative: string[] }export function outputMatcher( globs: readonly string[], compile?: (pattern: string) => Bun.Glob,): (rel: string) => booleanexport function isLiteralPattern(glob: string): booleanexport function taskGlob(pattern: string): Bun.Glob// A brace whose alternatives hold `/` expanded (Bun.Glob's scan skips one):// workspace discovery and the output scan (A-10).export function slashBraceExpansions(pattern: string): string[]export const GLOB_WILDCARDS: RegExp // /[*?{}]/ — a task glob's wildcardsexport const BUN_GLOB_WILDCARDS: RegExp // /[*?[\]{}]/ — Bun.Glob's ownTwo alphabets (item 667). In a TASK glob (cache.inputs.files,
cache.outputs.files, workspaceFiles) a bracket is a literal
character and there are no character classes: app/[id]/** is the
route directory, not the class that matches app/i. GLOB_WILDCARDS,
normalizeGlob, staticPrefix, isLiteralPattern and taskGlob read
that alphabet, and every task glob reaches Bun.Glob through
taskGlob, which escapes the brackets. The globs vx does not own keep
Bun.Glob’s grammar, class included: package-manager member globs
(normalizeBunGlob, BUN_GLOB_WILDCARDS), --filter path globs, and a
sandbox grant’s prefix (grantPrefix), which must stop where the scan
that expands the grant sees a wildcard.
toPosix(p)— replaces everypath.sepwith/.relPosix(from, to)—path.relative(from, to)thentoPosix.normalizeGlob(g)— the spellings a reader accepts but a matcher turns into nothing: a leading./, an inner/./, a doubled//, a trailing/on a pattern (→/**); after an optional!. The one rule behind the input/output resolver (asTrees), the watch loop’s output containers, the subtree short-circuit and the schema’s “names the directory itself” refusal (2026-09-10). A task glob’s escaped bracket\[becomes the bare one, so both spellings are one literal.normalizeBunGlob(g)— the same spellings for a glob inBun.Glob’s own alphabet (the workspace member globs, a sandbox grant):\[stays escaped, since there it is what keeps a bracket from opening a class.staticPrefix(g)— the wildcard-free head of a glob, whole components only; a brace set counts as a wildcard, and a literal’s trailing slash is dropped (the prefix is a directory either way). Normalizes the spelling FIRST — sharing the function was not enough to make its callers agree, because the sandbox joins the prefix onto a directory andpath.joinfolds./,//and/./on the way while the deferral gate compares the strings raw (item 441). Shared by the deferral gate, the restore tier, the stable-key reach and the watch loop’s output container.grantPrefix(g)—staticPrefixinBun.Glob’s alphabet, for a sandbox write grant: the sandbox baseline’s directory, and the directory the empty-grant warning names.wholeSubtreePrefixes(globs)— the<dir>/**directories a task’s outputs cover whole, ornull; normalizes first.asTrees(patterns)— a literal entry is the file OR its whole tree, so every literal compiles to itself plus<path>/**. It lives here, not beside the resolver, because it is not only the resolver’s rule: it decides what a clean DELETES, sograph’s overlapping-output refusal has to read the same one, andgraphmay not importcache(item 442). Re-exported bycache/index.ts, which is still its contract for the resolver.isLiteralPattern(g)— true when a task pattern carries no wildcard, so it names exactly one path and may be compared as a STRING; anything holding*,?or a brace alternation must be MATCHED instead (a bracket is literal). The character set is the whole content, and it lives here for the reasonasTreesdoes: four places asked this question andgraph/task-graph.tsasked it without{}, sodist/{a,b}.txtcounted as a literal and the overlapping-output refusal compared it todist/a.txtas two unequal strings — the two tasks were accepted and then deleted each other’s outputs, green, every run (item 495).taskGlob(p)— compile a task glob forBun.Globwith every bare bracket escaped. The one door: a site that compiled a task glob itself is a route directory that keys nothing.
A workspace cloned on Windows would otherwise produce a different
cache key than the same workspace on Linux for the same task — the
filesystem walk yields src\index.ts vs src/index.ts. Folding
those into the hash differently is the kind of cross-platform paper
cut we don’t want.
vx is POSIX-shell only at the runner level, so Windows isn’t officially supported anyway — but normalizing cache-key paths costs nothing and keeps things robust.
tests/util-paths.test.ts (each helper, incl. the spellings
staticPrefix folds), tests/dot-slash-globs.test.ts
(the spellings normalizeGlob folds, end to end), tests/output-dirs.test.ts
(wholeSubtreePrefixes behind the directory proof) and
tests/watch-rules.test.ts (staticPrefix behind the output
container), tests/task-glob-brackets.test.ts (a bracket route directory
through inputs, outputs, workspaceFiles, --affected and the additive
hit, both spellings); transitively through tests/cache.test.ts (cache-key
determinism) and tests/inputs.test.ts (glob result shapes).