src/workspace/workspace.ts — workspace discovery
Purpose
Section titled “Purpose”Package globs come from the package manager’s manifest and take its
full grammar: a negated entry (!packages/fixtures, !**/test/**)
subtracts from what the positive globs found — a literal one excludes
its tree, and a wildcard one is matched against the member’s manifest
(<pattern>/package.json) as pnpm matches it, so !**/test/** excludes
packages/test itself (item 986) — in both discovery and the root-claim walk.
A symlinked member is found by any glob without ** (packages/*,
packages/{a,b}, pack*/*); only packages/* found one until item 987.
Under ** links are not followed, so the scan never walks a pnpm
node_modules link farm. Handed to the
glob engine raw, a leading ! negated the whole pattern and made every
manifest in the tree a member (2026-09-10). Every entry goes through
normalizeBunGlob first: !./packages/legacy and !packages//legacy
excluded nothing until they did (same day). A trailing slash is dropped
before that, as npm and pnpm drop it: normalizeBunGlob gives a task
glob’s trailing slash the meaning /**, and packages/*/ found every
example and fixture package at any depth (item 985). A member glob keeps the
package manager’s grammar, so a bracket there is a class and \[ a
literal bracket — unlike a task glob, where a bracket is literal (item
667). The one form it refuses is extglob (!(…), @(…), +(…),
*(…), ?(…)): npm and yarn read packages/!(x) as an exclusion,
Bun.Glob has no extglob and its scan widened the segment to a
wildcard (x became a project, turborepo#3766), so assertGlobList
refuses the entry by name, with the exact ! rewrite when the group is
a whole segment of plain names. A brace whose alternatives hold a
slash (packages/{a,nested/b}) is scanned as its expansions:
Bun.Glob’s scan found nothing for one, though its match reads it, so
both packages vanished while the root still claimed them (D-2).
Find the workspace root, enumerate its projects, and resolve the
cache directory. Supports pnpm / npm / yarn / Bun workspaces, plus a
single-project mode (bare package.json with no workspaces field).
Public surface
Section titled “Public surface”export interface PackageJson { name: string version?: string dependencies?: Record<string, string> devDependencies?: Record<string, string> peerDependencies?: Record<string, string> optionalDependencies?: Record<string, string> workspaces?: string[] | { packages?: string[] }}
export interface Workspace { root: string packageGlobs: string[] // patterns relative to root}
export interface ProjectMeta { name: string // canonical from package.json dir: string // absolute project directory packageJson: PackageJson configPath: string | null // absolute path to vx.config.{ts,mts,js,mjs}}
export function findWorkspaceRoot(start: string, reads?: LoadReads): Promise<string>export function loadWorkspace(root: string, reads?: LoadReads): Promise<Workspace>export function listProjects(workspace: Workspace): Promise<ProjectMeta[]>export function resolveCacheDir(root: string, config: WorkspaceConfig | null): string
// A loaded project: its canonical name, directory and evaluated config.// `ProjectMeta` is what discovery finds; this is what a run reads.export interface ProjectEntry { name: string dir: string config: ProjectConfig}
// Workspace members whose package globs match no directory — `vx run`// warns with `unreachedHint`, which names them and what to check.export function unreachedPackages(workspace: Workspace): Promise<string[]>export function unreachedHint(unreached: readonly string[]): string
// The directories a recursive watch must cover to see every member.export function memberBaseDirs(workspace: Workspace): string[]
// A project's config file names, in the order discovery prefers them.export const PROJECT_CONFIG_FILENAMES: string[]From src/workspace/load-reads.ts, what one load has read of the root:
// Absolute path → the bytes, or null when no file is there.export type LoadReads = Map<string, Promise<Uint8Array | null>>// `file`'s bytes through `reads`: probed and read at most once per map.export function readOnce(reads: LoadReads | undefined, file: string): Promise<Uint8Array | null>A run reads the root manifest once. findWorkspaceRoot, loadWorkspace
and computeWorkspaceFingerprints each read pnpm-workspace.yaml for
themselves until 2026-09-24 — three probes and three reads per run;
prepareRun now hands all three one LoadReads, and so do the verbs
that pair the first two (show, lock, init, watch, the
selection pass, the doctor). The map is the load’s and dies with it:
a vx watch cycle is a new run and reads the file afresh
(tests/load-reads.test.ts holds both). The probe stays ahead of the
read — most names asked about are absent, and a failed read costs
90–200 µs building its error where exists() answers in 15–40 µs.
Discovery rules
Section titled “Discovery rules”findWorkspaceRoot(start)
Section titled “findWorkspaceRoot(start)”Walks up from start to the filesystem root. A directory is a root
CANDIDATE if it contains either:
pnpm-workspace.yaml, ORpackage.json(with or without aworkspacesfield).
The nearest candidate that CLAIMS start wins — one of the
directories between it and start matches one of its package globs.
Every member has its own package.json, so first-match-wins would make
a run from inside a package treat that package as the whole workspace:
^task edges vanish, upstream hashes drop out of the cache key (stale
hits), and a second cache dir appears under the member. Claiming reads
the same globs loadWorkspace applies, and only a directory holding a
manifest can be the claimed member, as only such a directory is listed,
so “the root that claims me” and “the root that lists me as a project”
cannot diverge. They did until item 989: packages/* matched a
manifest-less packages/tools, and the standalone package below it ran
in a workspace that does not list it. A pnpm-workspace.yaml is a hard
root, as pnpm has it: the walk stops at the nearest one, listed by an
outer workspace or not. From apps/inner the walk went past its own file
to the outer workspace while apps/inner/pkgs/x stopped there, two roots
and two caches for one tree, until item 990.
When nothing claims start — a standalone package, or a subdirectory
of a single-project repo — the nearest candidate wins. A bare
package.json without workspaces means single-project mode: the root
itself IS the project. Throws a UserError if no candidate is found.
unreachedPackages(workspace) is the failure-path check for that mode:
one shallow scan (two levels, node_modules and dot directories
skipped) for the package.json files the missing globs never reach, and
unreachedHint is the line vx init and vx run print for them —
the cause, the packages, the workspaces entry to add.
loadWorkspace(root, reads?)
Section titled “loadWorkspace(root, reads?)”Reads the package-glob list (through reads, so the manifest
findWorkspaceRoot just read is not read again):
| Manager | Source |
|---|---|
| pnpm | pnpm-workspace.yaml’s packages: field (via Bun.YAML.parse) |
| npm / yarn / bun (new) | package.json workspaces: string[] |
| yarn (legacy) | package.json workspaces: { packages: string[] } |
| single project | package.json without workspaces → returns ['.'] |
listProjects(workspace)
Section titled “listProjects(workspace)”Globs every package.json matching the patterns (Bun.Glob,
onlyFiles: true, dot: false). For each:
- Skip if no
namefield. - Detect duplicate package names → throws
UserErrorwith both root-relative paths and the way on (rename one, or a!glob). pnpm accepts a repeated name (sveltejs/kit’s test apps); vx cannot, since a project is addressed by its name. - Find the first existing
vx.config.{ts,mts,js,mjs}sibling; that becomesconfigPath. Projects without a config keepconfigPath: null— they’re still in the workspace graph (so cross-package deps work) but contribute no tasks. node_modulespaths are explicitly skipped even when a pathological**glob would match them.- The root package is a project too when it holds a
vx.config.{ts,mts,js,mjs}and no glob lists.(D-39): a workspace-root task (Turbo’s//#task, an Nx root project) without changing the package manager’s member list. Its globs stop at every member, as any parent project’s do. Design:docs/design/root-project-2026-09-28.md.
Returns the project list sorted by name.
resolveCacheDir(root, config)
Section titled “resolveCacheDir(root, config)”Resolves the cache directory:
config?.cacheDir(set viavx.workspace.ts) is honored. Relative paths resolve againstroot; absolute paths pass through.- Default:
<root>/.vx/cache.
Used by prepareRun (so run and planRun), the doctor, and every
reading verb through cli/workspace-config.ts — vx cache prune
included (cliCacheDir: --cache-dir, else this over the config with
the plugin config stage applied).
What this does NOT do
Section titled “What this does NOT do”- Doesn’t load configs.
loadProjectConfigdoes (seeproject-loader.md). Discovery is purely about “what packages exist and where?” - Doesn’t compute the package graph. That’s
package-graph.md. - Doesn’t filter projects.
--filter/--affectedhappen incli/select.ts(resolveFilters). - Doesn’t enforce project boundaries.
inputs.tsdoes, using the nested-dirs precomputation.
tests/workspace.test.ts:
- pnpm-workspace.yaml discovery.
- npm/yarn/bun
workspacesarray form. - yarn legacy
workspaces.packagesform. - bare-
package.jsonsingle-project mode (['.']). - duplicate-name detection.
findWorkspaceRootascending behavior + missing-root error.- glob behavior (one project, multiple projects, nested
node_modulesskipped).
Replacing this module
Section titled “Replacing this module”- Lerna / Rush layouts — replace
loadWorkspaceto parse the appropriate config file. KeepWorkspaceshape. - Custom workspace yaml — add another source to
loadWorkspace. - Project discovery beyond
package.json— e.g., readingpyproject.tomlfor non-JS deps. Would require generalizingProjectMeta.packageJsoninto a more abstractmanifestfield.