src/workspace/project-loader.ts — config file evaluation
Purpose
Section titled “Purpose”Evaluate a vx.config.{ts,mts,js,mjs} file and return the resolved
ProjectConfig object. Bun runs TypeScript natively, so the loader is
a thin wrapper around await import() with a content-hash query-string
bust to invalidate ESM module cache when the config changes.
Public surface
Section titled “Public surface”export async function loadProjectConfig(configPath: string): Promise<ProjectConfig>export async function loadWorkspaceConfig(workspaceRoot: string): Promise<WorkspaceConfig | null>Loading rules
Section titled “Loading rules”- Supported extensions:
.ts,.mts,.js,.mjs. Each is handed to a nativeawait import(). Bun resolves TypeScript natively — no transpile step, no separate loader, nojiti. - The import specifier is
<absolutePath>?vx-bust=<sha256-of-bytes>. Content changes produce a different query string → different ESM module identity → fresh evaluation. Same content → cached module (the no-op fast path). - The default export must be a non-null object. Anything else throws
"Project config at <path> did not export a default object".
Why content-hash cache-busting
Section titled “Why content-hash cache-busting”Bun’s ESM loader (like Node’s) caches modules by URL. Without a
cache-busting parameter, the second import() of the same path inside
one process returns the first loaded module, even after the file
changed on disk. With ?vx-bust=<sha>, a byte-level change produces a
different URL, forcing re-evaluation. mtime-based busting was an
earlier approach; we moved to content hashes because Bun’s stat()
sometimes reports mtimeNs: undefined, and content hash is what the
cache key already needs anyway.
This matters when:
- Tests run multiple
vx runcalls in the same process and edit configs between them. - (Future) a long-running watch mode reloads configs after edits.
For the normal one-shot vx run CLI invocation it doesn’t strictly
matter (each invocation is a fresh Bun process), but supporting it
costs nothing.
What this does NOT do
Section titled “What this does NOT do”- Validates each
TaskConfigshape at load time and surfacesUserErroron malformed input. Rules enforced:execmust be an object with a non-emptycommandstring.exec.persistentrejects malformed shapes; non-stringreadyWhenis rejected.cache+persistenttogether is a hard error (no exit to cache).- A task with no
execMUST declaredependsOn(group task) — a no-op task is rejected. cacherequiresexecAND requires bothinputs.filesandoutputs.filesarrays.dependsOnmust be astring[].descriptionmust be a string.
- Doesn’t sandbox the evaluated config — config code runs with the caller’s full Bun permissions. The user wrote it, the user trusts it.
- Doesn’t transform imports — relative imports inside the config
resolve normally via Bun’s loader. Including from
node_modules. This is what enables presets.
Caveats consumers should know
Section titled “Caveats consumers should know”- Side effects in config files run every load. Authors writing
process.env.SET_AT_LOAD_TIME = 'oops'will see that env mutation. Date.now()in config = always-different configHash. Resolved values get baked into the object, including non-deterministic ones. This is a footgun documented inarchitecture.md.- Imports from
node_modulesare cached by Bun, not by us. Within one Bun process, animport from 'pkg'resolves once and doesn’t reload even ifnode_modulesupdates. Fine for CLI use; matters if we add a daemon.
tests/project-loader.test.ts covers:
- Loads a default-exported object from
.mjs. - Throws on no-default export.
- Throws on non-object default export.
- Group-task validation (accepts task with only
dependsOn; rejects task with neitherexecnordependsOn; rejectscacheon a group task). loadWorkspaceConfigreturns null when novx.workspace.*file exists, validatesconcurrencyandcacheDir.
Replacing this module
Section titled “Replacing this module”Drop in any function that takes an absolute config path and returns
ProjectConfig. Alternatives:
- esbuild / oxc-based loader — fastest TS evaluation but ships an extra dep and gives up Bun’s native TS support.
- Worker-based isolation — load configs in a worker thread for better cleanup / cache invalidation in long-running processes.