src/workspace/config-schema.ts — what a config may say
Purpose
Section titled “Purpose”The schema validators for vx.config.* and vx.workspace.*: every
object level, every field’s shape, every rule a value must satisfy, and
the message that names the level, the accepted list and the nearest
spelling when a key is unknown. Split from project-loader.ts on
2026-09-10 so the two concerns read separately: this module decides
what a config may SAY, the loader decides HOW a file is evaluated.
Public surface
Section titled “Public surface”export function validateProjectConfig(config: ProjectConfig, configPath: string): voidexport function validateWorkspace(config: WorkspaceConfig, configPath: string): void// Why `name` cannot be a task name (empty, padded, holds `#`, …), or null; `vx init` skips such a scriptexport function taskNameProblem(name: string): string | null// A level's field set → the fields a release removed from it (item H-3)export const REMOVED_FIELDS: ReadonlyMap< ReadonlySet<string>, Readonly<Record<string, { removedIn: string; use: string }>>>
// json-data.ts: the JSON-data rule (item 701)export interface NonJsonValue { path: string // as the validator writes it: tasks.build.description, dependsOn[1] is: string // a function, NaN, an instance of Map, …}export function nonJsonPaths(value: unknown): NonJsonValue[]export function nonJsonMessage(configPath: string, found: NonJsonValue): stringvalidateProjectConfig is the one boundary three readers cross with the
same object shape: the loader after evaluation, lockfile.ts on a frozen
entry read back from vx-lock.json (a hand-editable file — the same
boundary), and orchestrator/projects.ts after each plugin’s project
edit (a broken edit is refused naming the plugin). validateWorkspace
runs once per vx.workspace.* load: fields, plugin shapes, and the verb
rules (a plugin may not shadow a core verb; a verb has one owner).
- A project config is JSON data (item 701). The key folds
JSON.stringifyof each task’s config,vx lockstores that JSON, and a repeat load crosses back from the config worker as JSON, so a value JSON cannot carry was refused by one path and dropped by the other (description: () => 'x'failedvx runand passedvx watch), or sat outside the key on both (aMapasexec.sandbox, a hole independsOn).nonJsonPathsnames every such value — a function, symbol or bigint,NaN/±Infinity,undefinedinside an array (a hole too), a cycle, an object whose prototype is neitherObject.prototypenornulland is not an array — andvalidateProjectConfigrefuses the first one before the schema, after the file’s path: “tasks.build.description is a function — a config must be JSON data, because the cache key folds its JSON”. AnundefinedPROPERTY is allowed (JSON drops it, the schema reads it as absent, conditional spreads write it). The config worker (config-eval.ts) and the playground’s worker run the same function, embedded by its source text (nonJsonPaths.toString()), before theirJSON.stringify, and report withnonJsonMessage, so there is one copy of the rule and one message. It changes no key: a config that passes is the object it was.validateWorkspacedoes not apply it: plugins are objects of functions, the file never crosses the worker, and no key folds it. - A removed field is refused naming its replacement. Before a key
is called unknown,
REMOVED_FIELDSis consulted for the level’s field set:exec.resourcesreads “has field “resources”, which vx 0.0.19 removed — use@vzn/vx-schedule-history…”, not “unknown field” (design/versioning-1.0.md§ Deprecation). Entries stay for good. - Unknown keys are refused at every object level —
tasks, the task,exec,exec.env,exec.persistent,exec.sandboxand itsallow/deny/ignore,cache,cache.inputs,cache.outputs, and the workspace top level. The message is<level> has unknown field "<key>" (allowed: …)plus— did you mean <x>?when a candidate is within two edits (util/edit-distance.ts), never a guess beyond that. cacheneeds bothinputsandoutputs; a persistent task may not carrycache;dependsOnentries are validated as specs.- Globs may not carry a
..path segment, a negation alone, or a double negation; workspace-anchored globs have their own checks. - Timeouts are bounded by
MAX_TIMEOUT_MS(a larger delay would fire at 1 ms);sandboxgrants are typed per field (paths, names, booleans,network,unixSockets).
What it does NOT do
Section titled “What it does NOT do”- Evaluate or read any file; it sees an object.
- Fold anything into a cache key —
task-hash.tsdoes, from the validated object. - Apply defaults. A validated config is the user’s object, untouched.
tests/schema-unknown-keys.test.ts (the walk over every level, its
level list pinned); tests/config-eval.test.ts § item 701 (each
non-JSON kind refused by the first load and by the worker with one
message, the controls, the key’s JSON unchanged); tests/schema-doc-drift.test.ts (every rule and
message against docs/schema.md); tests/project-loader.test.ts,
tests/timeout-bounds.test.ts, tests/workspace-files.test.ts,
tests/inputs-resolution.test.ts (individual rules);
tests/sandbox-hint.test.ts (a runtime message’s field names validate
here).
Replacing this module
Section titled “Replacing this module”A stricter or looser schema is a change here and in docs/schema.md
together — the drift test holds the two to each other.