@vzn/vx 0.0.21 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
|
@@ -25,12 +25,119 @@
|
|
|
25
25
|
// closure, and the containment channel already selects the project that
|
|
26
26
|
// owns it.
|
|
27
27
|
|
|
28
|
+
import { existsSync } from 'node:fs'
|
|
28
29
|
import { realpath } from 'node:fs/promises'
|
|
30
|
+
import { builtinModules } from 'node:module'
|
|
29
31
|
import path from 'node:path'
|
|
30
32
|
import type { ProjectMeta } from './workspace.js'
|
|
31
33
|
|
|
34
|
+
const BUILTINS = new Set(builtinModules)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One transpiler per loader, made on first use: constructing one costs
|
|
38
|
+
* 42 µs against 12 µs for the scan itself (measured 2026-09-16), and a
|
|
39
|
+
* fresh one per config put 1000 cold evaluations 130 ms behind.
|
|
40
|
+
*/
|
|
41
|
+
const transpilers: Partial<Record<'ts' | 'js', Bun.Transpiler>> = {}
|
|
42
|
+
function scanner(loader: 'ts' | 'js'): Bun.Transpiler {
|
|
43
|
+
return (transpilers[loader] ??= new Bun.Transpiler({ loader }))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether Bun's own parser finds an ESM `export` in `source` — the one
|
|
48
|
+
* syntax that makes Bun evaluate a file as a module whatever else it says.
|
|
49
|
+
* Without one, a file that touches `module`, `exports`, `require`, `this`
|
|
50
|
+
* or `__dirname` at the top level is CommonJS to Bun. False on source the
|
|
51
|
+
* parser refuses: the evaluation names that error.
|
|
52
|
+
*/
|
|
53
|
+
export function hasEsmExport(source: string, loader: 'ts' | 'js'): boolean {
|
|
54
|
+
try {
|
|
55
|
+
return scanner(loader).scan(source).exports.length > 0
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The package a bare specifier names: `@scope/name` or the first segment. */
|
|
62
|
+
function packageOf(spec: string): string {
|
|
63
|
+
const parts = spec.split('/')
|
|
64
|
+
return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SPECIFIER =
|
|
68
|
+
/\b(?:from|import)\s*\(?\s*["']([^"'./][^"']*)["']|\brequire\s*\(\s*["']([^"'./][^"']*)["']/g
|
|
69
|
+
|
|
70
|
+
/** Whether a specifier is one the walk would have to look up at all. */
|
|
71
|
+
function needsLookup(spec: string): boolean {
|
|
72
|
+
if (spec.includes(':') || BUILTINS.has(spec)) return false
|
|
73
|
+
return !(spec === '@vzn/vx' || spec.startsWith('@vzn/vx/'))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A textual pass before the transpiler's: its scan costs ~50 µs per config
|
|
78
|
+
* in situ (1000 cold evaluations: 50 ms, measured 2026-09-16), and most
|
|
79
|
+
* configs import `@vzn/vx` and nothing else. Every static specifier is a
|
|
80
|
+
* quoted string after `from`, `import` or `require(`; a candidate found
|
|
81
|
+
* here is checked exactly by the scan (a comment or a type-only import is
|
|
82
|
+
* the scan's to drop), and a source with no candidate skips it.
|
|
83
|
+
*/
|
|
84
|
+
function hasBareCandidate(source: string): boolean {
|
|
85
|
+
SPECIFIER.lastIndex = 0
|
|
86
|
+
for (let m = SPECIFIER.exec(source); m !== null; m = SPECIFIER.exec(source)) {
|
|
87
|
+
if (needsLookup(m[1] ?? m[2] ?? '')) return true
|
|
88
|
+
}
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The bare specifiers of `source` that no `node_modules/<package>` above
|
|
94
|
+
* `fromDir` provides — the imports Bun would AUTO-INSTALL from the npm
|
|
95
|
+
* registry rather than fail, when no `node_modules` exists anywhere above
|
|
96
|
+
* (measured 2026-09-16: sixteen registry connections and 150 ms before
|
|
97
|
+
* "cannot find"; with one present, 0 and 1 ms). A config is evaluated in
|
|
98
|
+
* the user's process with the user's network: a fresh clone before its
|
|
99
|
+
* install, or a typo, must be a refusal, never a download. Builtins
|
|
100
|
+
* (`node:fs`, `fs`, `bun:sqlite`) and `@vzn/vx` (served by the core alias
|
|
101
|
+
* inside the compiled binary) are never in the list; relative and absolute
|
|
102
|
+
* specifiers resolve by path and are the loader's to refuse.
|
|
103
|
+
*/
|
|
104
|
+
export function unprovidedBareImports(
|
|
105
|
+
source: string,
|
|
106
|
+
fromDir: string,
|
|
107
|
+
loader: 'ts' | 'js',
|
|
108
|
+
): string[] {
|
|
109
|
+
if (!hasBareCandidate(source)) return []
|
|
110
|
+
let specifiers: string[]
|
|
111
|
+
try {
|
|
112
|
+
specifiers = scanner(loader)
|
|
113
|
+
.scanImports(source)
|
|
114
|
+
.map((i) => i.path)
|
|
115
|
+
} catch {
|
|
116
|
+
return [] // unparseable: the evaluation names the syntax error
|
|
117
|
+
}
|
|
118
|
+
const out: string[] = []
|
|
119
|
+
for (const spec of specifiers) {
|
|
120
|
+
if (spec.startsWith('.') || spec.startsWith('/') || !needsLookup(spec)) continue
|
|
121
|
+
const pkg = packageOf(spec)
|
|
122
|
+
let dir = path.resolve(fromDir)
|
|
123
|
+
let provided = false
|
|
124
|
+
for (;;) {
|
|
125
|
+
if (existsSync(path.join(dir, 'node_modules', pkg))) {
|
|
126
|
+
provided = true
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
const up = path.dirname(dir)
|
|
130
|
+
if (up === dir) break
|
|
131
|
+
dir = up
|
|
132
|
+
}
|
|
133
|
+
if (!provided && !out.includes(spec)) out.push(spec)
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
32
138
|
/**
|
|
33
|
-
* Absolute resolved targets of the RELATIVE specifiers in `source
|
|
139
|
+
* Absolute resolved targets of the RELATIVE specifiers in `source`; one that
|
|
140
|
+
* does not resolve contributes the paths it names.
|
|
34
141
|
*
|
|
35
142
|
* Paths come back realpath'd, because `Bun.resolveSync` realpaths them — see
|
|
36
143
|
* the caller, which realpaths everything it compares against for exactly that
|
|
@@ -40,7 +147,9 @@ import type { ProjectMeta } from './workspace.js'
|
|
|
40
147
|
function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'): string[] {
|
|
41
148
|
let specifiers: string[]
|
|
42
149
|
try {
|
|
43
|
-
specifiers =
|
|
150
|
+
specifiers = scanner(loader)
|
|
151
|
+
.scanImports(source)
|
|
152
|
+
.map((i) => i.path)
|
|
44
153
|
} catch {
|
|
45
154
|
return [] // unparseable source contributes no edges
|
|
46
155
|
}
|
|
@@ -50,9 +159,18 @@ function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'):
|
|
|
50
159
|
try {
|
|
51
160
|
out.push(Bun.resolveSync(spec, fromDir))
|
|
52
161
|
} catch {
|
|
53
|
-
// Unresolvable
|
|
54
|
-
//
|
|
55
|
-
//
|
|
162
|
+
// Unresolvable: most often the change itself deleted or renamed the
|
|
163
|
+
// target, and the config that imports it is exactly the project the
|
|
164
|
+
// change broke. The edge is the path the specifier names (and, bare
|
|
165
|
+
// of an extension, the files Bun would have tried), so the deleted
|
|
166
|
+
// path in the diff still reaches its importer (item 958). Selection
|
|
167
|
+
// may widen; it is never hashed.
|
|
168
|
+
const named = path.resolve(fromDir, spec)
|
|
169
|
+
out.push(named)
|
|
170
|
+
if (path.extname(spec) === '') {
|
|
171
|
+
for (const ext of RESOLVED_EXTENSIONS)
|
|
172
|
+
out.push(named + ext, path.join(named, 'index' + ext))
|
|
173
|
+
}
|
|
56
174
|
}
|
|
57
175
|
}
|
|
58
176
|
return out
|
|
@@ -92,6 +210,9 @@ async function realDirIndex(projects: readonly ProjectMeta[]): Promise<Map<strin
|
|
|
92
210
|
|
|
93
211
|
const TS_EXT = new Set(['.ts', '.mts', '.cts'])
|
|
94
212
|
|
|
213
|
+
/** The extensions Bun tries for a specifier that names none. */
|
|
214
|
+
const RESOLVED_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']
|
|
215
|
+
|
|
95
216
|
/** The deepest project containing `file`, or undefined when none does. */
|
|
96
217
|
function ownerOf(file: string, dirToName: ReadonlyMap<string, string>): string | undefined {
|
|
97
218
|
let dir = path.dirname(file)
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// it accepts, and `validateProjectConfig` is the one boundary the loader,
|
|
6
6
|
// the lockfile's frozen path and the plugin `project` stage all cross.
|
|
7
7
|
|
|
8
|
+
import path from 'node:path'
|
|
8
9
|
import {
|
|
9
10
|
PLUGIN_FUNCTION_HOOKS,
|
|
10
11
|
PLUGIN_HOOKS,
|
|
@@ -18,17 +19,67 @@ import {
|
|
|
18
19
|
MAX_TIMEOUT_MS,
|
|
19
20
|
nearest,
|
|
20
21
|
normalizeGlob,
|
|
22
|
+
parseDuration,
|
|
23
|
+
parseSize,
|
|
21
24
|
UserError,
|
|
25
|
+
BUN_GLOB_WILDCARDS,
|
|
22
26
|
} from '../util/index.js'
|
|
23
|
-
import {
|
|
27
|
+
import { nonJsonMessage, nonJsonPaths } from './json-data.js'
|
|
24
28
|
|
|
25
29
|
// Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
|
|
26
30
|
// the same reason the task levels reject them: `plugin: [...]` (singular)
|
|
27
31
|
// declared no plugins and ran the workspace bare, `cacheDirectory` left the
|
|
28
32
|
// cache where it was — a config that loads and quietly does nothing it says.
|
|
29
|
-
const WORKSPACE_FIELDS = new Set([
|
|
33
|
+
const WORKSPACE_FIELDS = new Set([
|
|
34
|
+
'concurrency',
|
|
35
|
+
'cacheDir',
|
|
36
|
+
'timeout',
|
|
37
|
+
'cacheRetention',
|
|
38
|
+
'plugins',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
|
|
42
|
+
|
|
43
|
+
function validateRetention(retention: unknown, configPath: string): void {
|
|
44
|
+
const where = `${configPath}: \`cacheRetention\``
|
|
45
|
+
if (retention === null || typeof retention !== 'object' || Array.isArray(retention)) {
|
|
46
|
+
throw new UserError(`${where} must be { olderThan?: '30d', maxSize?: '10G' }`)
|
|
47
|
+
}
|
|
48
|
+
assertKnownFields(retention as Record<string, unknown>, RETENTION_FIELDS, where)
|
|
49
|
+
const { olderThan, maxSize } = retention as { olderThan?: unknown; maxSize?: unknown }
|
|
50
|
+
// An empty object would load and evict nothing, which reads as a policy.
|
|
51
|
+
if (olderThan === undefined && maxSize === undefined) {
|
|
52
|
+
throw new UserError(`${where} names neither \`olderThan\` nor \`maxSize\``)
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
olderThan !== undefined &&
|
|
56
|
+
(typeof olderThan !== 'string' || parseDuration(olderThan) === null)
|
|
57
|
+
) {
|
|
58
|
+
throw new UserError(`${where}.olderThan must be a duration like '30d', '12h', '90m' or '45s'`)
|
|
59
|
+
}
|
|
60
|
+
if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
|
|
61
|
+
throw new UserError(`${where}.maxSize must be a size like '10G', '500MB' or '64KB'`)
|
|
62
|
+
}
|
|
63
|
+
// The bounds `vx cache prune` refuses, for the same reason: each evicts
|
|
64
|
+
// every entry, and here it would do so at the end of every run, the
|
|
65
|
+
// entries that run just saved included, so nothing ever hits (item 969).
|
|
66
|
+
if (olderThan !== undefined && parseDuration(olderThan) === 0) {
|
|
67
|
+
throw new UserError(`${where}.olderThan of 0 evicts every entry after every run`)
|
|
68
|
+
}
|
|
69
|
+
if (maxSize !== undefined && parseSize(maxSize) === 0) {
|
|
70
|
+
throw new UserError(`${where}.maxSize of 0 evicts every entry after every run`)
|
|
71
|
+
}
|
|
72
|
+
if (maxSize !== undefined && /^\d+$/.test(maxSize)) {
|
|
73
|
+
throw new UserError(
|
|
74
|
+
`${where}.maxSize '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
30
78
|
|
|
31
79
|
export function validateWorkspace(config: WorkspaceConfig, configPath: string): void {
|
|
80
|
+
// No JSON-data rule here, unlike `validateProjectConfig`: `plugins` holds
|
|
81
|
+
// objects of functions by design, the file is evaluated in-process on
|
|
82
|
+
// every load (never through the config worker), and no key folds its JSON.
|
|
32
83
|
assertKnownFields(config, WORKSPACE_FIELDS, configPath)
|
|
33
84
|
if (config.concurrency !== undefined) {
|
|
34
85
|
if (
|
|
@@ -53,6 +104,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
53
104
|
}
|
|
54
105
|
assertTimeoutInRange(config.timeout, `${configPath}: \`timeout\``)
|
|
55
106
|
}
|
|
107
|
+
if (config.cacheRetention !== undefined) validateRetention(config.cacheRetention, configPath)
|
|
56
108
|
if (config.plugins !== undefined) {
|
|
57
109
|
if (!Array.isArray(config.plugins)) {
|
|
58
110
|
throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
|
|
@@ -150,11 +202,20 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
150
202
|
)
|
|
151
203
|
}
|
|
152
204
|
for (const file of claim.files as unknown[]) {
|
|
153
|
-
// A name
|
|
154
|
-
//
|
|
155
|
-
|
|
205
|
+
// A bare name at the workspace root: a lockfile core folds (taken
|
|
206
|
+
// out of the fingerprint), or a root file the plugin's stages read
|
|
207
|
+
// (`turbo.json`: nothing to take out; `--affected` and `vx watch`
|
|
208
|
+
// ask the claimant about it, item 961). A path is refused: the
|
|
209
|
+
// root arm `vx watch` keeps is not recursive.
|
|
210
|
+
if (
|
|
211
|
+
typeof file !== 'string' ||
|
|
212
|
+
file === '' ||
|
|
213
|
+
file === '.' ||
|
|
214
|
+
file === '..' ||
|
|
215
|
+
/[/\\]/.test(file)
|
|
216
|
+
) {
|
|
156
217
|
throw new UserError(
|
|
157
|
-
`${configPath}: plugin '${plug.name}' claims fingerprint file ${JSON.stringify(file)}, which
|
|
218
|
+
`${configPath}: plugin '${plug.name}' claims fingerprint file ${JSON.stringify(file)}, which is not a file name at the workspace root`,
|
|
158
219
|
)
|
|
159
220
|
}
|
|
160
221
|
const owner = fileClaimants.get(file)
|
|
@@ -186,6 +247,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
186
247
|
* back from `vx-lock.json` (a hand-editable file — same boundary).
|
|
187
248
|
*/
|
|
188
249
|
export function validateProjectConfig(config: ProjectConfig, configPath: string): void {
|
|
250
|
+
// Before the schema, on the object as it stands: the key folds its JSON,
|
|
251
|
+
// so a value JSON cannot carry is refused here and in the config worker
|
|
252
|
+
// alike (json-data.ts). A plugin's `project` edit and a lock entry cross
|
|
253
|
+
// this line too.
|
|
254
|
+
const [nonJson] = nonJsonPaths(config)
|
|
255
|
+
if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
|
|
189
256
|
// The top level too: `task:` (singular) loaded as a project with no
|
|
190
257
|
// tasks and every request against it said "no projects declare".
|
|
191
258
|
assertKnownFields(config, PROJECT_FIELDS, configPath)
|
|
@@ -199,6 +266,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
199
266
|
}
|
|
200
267
|
for (const [name, task] of Object.entries(tasks)) {
|
|
201
268
|
const where = `${configPath}: tasks.${name}`
|
|
269
|
+
assertTaskName(name, configPath)
|
|
202
270
|
if (!task || typeof task !== 'object') {
|
|
203
271
|
throw new UserError(`${where} must be an object`)
|
|
204
272
|
}
|
|
@@ -234,6 +302,15 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
234
302
|
if (remote !== undefined && typeof remote !== 'boolean' && remote !== 'only') {
|
|
235
303
|
throw new UserError(`${where}.exec.remote must be a boolean or 'only' (or omitted)`)
|
|
236
304
|
}
|
|
305
|
+
// Documented and never held: an uncached 'only' task loaded, and the
|
|
306
|
+
// REAPI executor refused it at run time for want of described inputs
|
|
307
|
+
// (item 1001).
|
|
308
|
+
if (remote === 'only' && cache === undefined) {
|
|
309
|
+
throw new UserError(
|
|
310
|
+
`${where}.exec.remote 'only' needs \`cache\`: its inputs are what a worker reproduces ` +
|
|
311
|
+
`and its key is the address of its remote record`,
|
|
312
|
+
)
|
|
313
|
+
}
|
|
237
314
|
const env = (exec as { env?: unknown }).env
|
|
238
315
|
if (env !== undefined) {
|
|
239
316
|
if (typeof env !== 'object' || env === null) {
|
|
@@ -247,12 +324,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
247
324
|
// A non-array here reaches `buildIsolatedEnv`'s `for (const name of
|
|
248
325
|
// passThrough)` — a number throws "not iterable" mid-run, a string
|
|
249
326
|
// silently char-iterates. Fail loud at load with a config pointer.
|
|
250
|
-
if (
|
|
251
|
-
!Array.isArray(passThrough) ||
|
|
252
|
-
passThrough.some((n) => typeof n !== 'string' || n.length === 0)
|
|
253
|
-
) {
|
|
327
|
+
if (!Array.isArray(passThrough) || passThrough.some((n) => !isEnvName(n))) {
|
|
254
328
|
throw new UserError(
|
|
255
|
-
`${where}.exec.env.passThrough must be an array of non-empty
|
|
329
|
+
`${where}.exec.env.passThrough must be an array of env var names (non-empty, no '=' or NUL)`,
|
|
256
330
|
)
|
|
257
331
|
}
|
|
258
332
|
}
|
|
@@ -264,8 +338,13 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
264
338
|
)
|
|
265
339
|
}
|
|
266
340
|
for (const [k, val] of Object.entries(define as Record<string, unknown>)) {
|
|
267
|
-
if (
|
|
268
|
-
throw new UserError(
|
|
341
|
+
if (!isEnvName(k)) {
|
|
342
|
+
throw new UserError(
|
|
343
|
+
`${where}.exec.env.define: ${JSON.stringify(k)} is not an env var name (non-empty, no '=' or NUL)`,
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
if (typeof val !== 'string' || val.includes('\0')) {
|
|
347
|
+
throw new UserError(`${where}.exec.env.define.${k} must be a string with no NUL`)
|
|
269
348
|
}
|
|
270
349
|
}
|
|
271
350
|
}
|
|
@@ -287,6 +366,17 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
287
366
|
if (readyWhen !== undefined && typeof readyWhen !== 'string') {
|
|
288
367
|
throw new UserError(`${where}.exec.persistent.readyWhen must be a string regex`)
|
|
289
368
|
}
|
|
369
|
+
// Compiled here as the runner compiles it: an unbalanced `(` loaded
|
|
370
|
+
// and then failed the task as "internal error" (item 998).
|
|
371
|
+
if (readyWhen !== undefined) {
|
|
372
|
+
try {
|
|
373
|
+
new RegExp(readyWhen)
|
|
374
|
+
} catch (err) {
|
|
375
|
+
throw new UserError(
|
|
376
|
+
`${where}.exec.persistent.readyWhen is not a valid regex (${(err as Error).message})`,
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
290
380
|
if (cache !== undefined) {
|
|
291
381
|
throw new UserError(
|
|
292
382
|
`${where}: \`cache\` is not allowed on a persistent task — persistent tasks ` +
|
|
@@ -301,9 +391,13 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
301
391
|
}
|
|
302
392
|
}
|
|
303
393
|
} else {
|
|
304
|
-
// Group task: no exec, just dependencies.
|
|
305
|
-
//
|
|
306
|
-
//
|
|
394
|
+
// Group task: no exec, just dependencies. Only an ABSENT `dependsOn`
|
|
395
|
+
// is refused — a task with neither is a typo, not a declaration. An
|
|
396
|
+
// EMPTY one is deliberate and documented: `build: { dependsOn: [] }`
|
|
397
|
+
// is how a package consumed as source says it has nothing to build,
|
|
398
|
+
// so a dependant's `^build` finds it and waits on nothing
|
|
399
|
+
// (the quickstart). The comment here used to call that case
|
|
400
|
+
// "almost certainly a config mistake" while the docs taught it.
|
|
307
401
|
if (dependsOn === undefined) {
|
|
308
402
|
throw new UserError(
|
|
309
403
|
`${where}: a task with no \`exec\` must declare \`dependsOn\` ` +
|
|
@@ -346,10 +440,11 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
346
440
|
if (envList !== undefined) {
|
|
347
441
|
if (
|
|
348
442
|
!Array.isArray(envList) ||
|
|
349
|
-
|
|
443
|
+
// A NUL can be in no env name, and the key fold's delimiter is one.
|
|
444
|
+
envList.some((s) => !isEnvName(s))
|
|
350
445
|
) {
|
|
351
446
|
throw new UserError(
|
|
352
|
-
`${where}.cache.inputs.env must be an array of non-empty
|
|
447
|
+
`${where}.cache.inputs.env must be an array of env var names (non-empty, no '=' or NUL)`,
|
|
353
448
|
)
|
|
354
449
|
}
|
|
355
450
|
for (const name of envList as string[]) {
|
|
@@ -359,7 +454,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
359
454
|
// we add expansion later it'll be additive — until then,
|
|
360
455
|
// surface the footgun instead of returning '' for the
|
|
361
456
|
// literal env name `'VERCEL_*'`.
|
|
362
|
-
|
|
457
|
+
// An env name is no path, so a bracket is no route directory: the
|
|
458
|
+
// whole `Bun.Glob` alphabet stays refused here.
|
|
459
|
+
if (BUN_GLOB_WILDCARDS.test(name)) {
|
|
363
460
|
throw new UserError(
|
|
364
461
|
`${where}.cache.inputs.env: wildcards in env names are not supported ` +
|
|
365
462
|
`(got "${name}") — list explicit env var names instead`,
|
|
@@ -398,7 +495,8 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
398
495
|
}
|
|
399
496
|
if (namesDirItself(g)) {
|
|
400
497
|
throw new UserError(
|
|
401
|
-
`${where}.cache.outputs.files: "${g}" names the project directory itself and selects nothing —
|
|
498
|
+
`${where}.cache.outputs.files: "${g}" names the project directory itself and selects nothing — ` +
|
|
499
|
+
`name the directory the task writes, such as "dist/**"`,
|
|
402
500
|
)
|
|
403
501
|
}
|
|
404
502
|
if (g.startsWith('!')) {
|
|
@@ -408,6 +506,14 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
408
506
|
`path beginning with '!' and matches nothing. List the outputs you DO produce.`,
|
|
409
507
|
)
|
|
410
508
|
}
|
|
509
|
+
const own = ownFileCovered(g, configPath)
|
|
510
|
+
if (own !== null) {
|
|
511
|
+
throw new UserError(
|
|
512
|
+
`${where}.cache.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
|
|
513
|
+
`task's outputs before it runs and restores them on a hit, so the project would lose ` +
|
|
514
|
+
`its manifest and config. Name the directory the task writes, such as "dist/**".`,
|
|
515
|
+
)
|
|
516
|
+
}
|
|
411
517
|
}
|
|
412
518
|
// Same for inputs.files.
|
|
413
519
|
for (const g of (inputs as { files: unknown[] }).files) {
|
|
@@ -432,6 +538,15 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
432
538
|
`${where}.cache.inputs.files: "${g}" names the project directory itself and selects nothing — use "**" for everything under it`,
|
|
433
539
|
)
|
|
434
540
|
}
|
|
541
|
+
// A negated absolute path subtracts nothing (the positive globs are
|
|
542
|
+
// project-relative), so it would sit in the config as a silent no-op;
|
|
543
|
+
// `workspaceFiles` refuses both spellings the same way (item 679).
|
|
544
|
+
if (g.startsWith('!/')) {
|
|
545
|
+
throw new UserError(
|
|
546
|
+
`${where}.cache.inputs.files: absolute paths are not allowed (got "${g}") — ` +
|
|
547
|
+
`inputs must be project-relative globs`,
|
|
548
|
+
)
|
|
549
|
+
}
|
|
435
550
|
assertNotDoubleNegated(g, `${where}.cache.inputs.files`)
|
|
436
551
|
}
|
|
437
552
|
// A list of ONLY negations selects nothing at all. `resolveFiles`
|
|
@@ -519,6 +634,15 @@ const CACHE_INPUT_FIELDS = new Set([
|
|
|
519
634
|
const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles'])
|
|
520
635
|
|
|
521
636
|
function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: string): void {
|
|
637
|
+
// `typeof [] === 'object'`, so an array reaches here and its indices read
|
|
638
|
+
// as fields: `outputs: ['dist/**']` (Turbo's spelling) was refused as
|
|
639
|
+
// `unknown field "0"`. Name the shape instead, and the one meant.
|
|
640
|
+
if (Array.isArray(value)) {
|
|
641
|
+
throw new UserError(
|
|
642
|
+
`${where} must be an object (fields: ${[...allowed].sort().join(', ')}), not an array` +
|
|
643
|
+
(allowed.has('files') ? ' — did you mean `{ files: [...] }`?' : ''),
|
|
644
|
+
)
|
|
645
|
+
}
|
|
522
646
|
for (const key of Object.keys(value)) {
|
|
523
647
|
if (!allowed.has(key)) {
|
|
524
648
|
// The nearest accepted spelling first: the list says what the level
|
|
@@ -554,14 +678,16 @@ function assertTimeoutInRange(ms: number, where: string): void {
|
|
|
554
678
|
}
|
|
555
679
|
|
|
556
680
|
/**
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
* root: `cleanOutputs` rm()s resolved output paths before every run, and
|
|
562
|
-
* `Bun.Glob.scan` follows `..` out of its cwd, so a `..` glob is a data-loss
|
|
563
|
-
* vector (delete files outside the project / above the repo root).
|
|
681
|
+
* The project file an output glob would take, or null. `outputs: ['**']`
|
|
682
|
+
* loaded, and the clean before the run deleted the project's source,
|
|
683
|
+
* `package.json` and `vx.config` while the run reported success (item
|
|
684
|
+
* 1002); the message for `'.'` had even suggested `**`.
|
|
564
685
|
*/
|
|
686
|
+
function ownFileCovered(glob: string, configPath: string): string | null {
|
|
687
|
+
const g = new Bun.Glob(normalizeGlob(glob))
|
|
688
|
+
return ['package.json', path.basename(configPath)].find((f) => g.match(f)) ?? null
|
|
689
|
+
}
|
|
690
|
+
|
|
565
691
|
/**
|
|
566
692
|
* `.`, `./`, `././` (with or without a `!`) name the directory itself, which
|
|
567
693
|
* no matcher expands: the entry selected nothing and said so nowhere.
|
|
@@ -572,6 +698,15 @@ function namesDirItself(glob: string): boolean {
|
|
|
572
698
|
return g === '' || g === '/'
|
|
573
699
|
}
|
|
574
700
|
|
|
701
|
+
/**
|
|
702
|
+
* True when a glob contains a `..` PATH SEGMENT (`../x`, `a/../b`, `x/..`), which
|
|
703
|
+
* escapes its base dir — `foo..bar` / `a..b` inside a filename are fine. A
|
|
704
|
+
* leading negation marker is stripped first so `!../x` is caught too. Used to
|
|
705
|
+
* keep output globs inside the project and workspace globs inside the workspace
|
|
706
|
+
* root: `cleanOutputs` rm()s resolved output paths before every run, and
|
|
707
|
+
* `Bun.Glob.scan` follows `..` out of its cwd, so a `..` glob is a data-loss
|
|
708
|
+
* vector (delete files outside the project / above the repo root).
|
|
709
|
+
*/
|
|
575
710
|
function hasParentSegment(glob: string): boolean {
|
|
576
711
|
const g = glob.startsWith('!') ? glob.slice(1) : glob
|
|
577
712
|
return g.split('/').some((seg) => seg === '..')
|
|
@@ -616,35 +751,96 @@ function assertNotDoubleNegated(glob: string, where: string): void {
|
|
|
616
751
|
* next upstream change. `['buidl']` for `['build']` is the shape. Patterns,
|
|
617
752
|
* wildcards and negations stay silent (a preset-spread pattern legitimately
|
|
618
753
|
* matches nothing in some projects; excluding what is absent is harmless);
|
|
619
|
-
* an exact name must be named by some `dependsOn` entry
|
|
620
|
-
* entry's own `*` pattern.
|
|
754
|
+
* an exact name must be named by some `dependsOn` entry of a form that can
|
|
755
|
+
* reach the same node, exactly or by that entry's own `*` pattern. The form
|
|
756
|
+
* matters: `build` selects this project's `build` and `^build` only other
|
|
757
|
+
* projects', so `['build']` against `dependsOn: ['^build']` folded nothing
|
|
758
|
+
* and served a stale hit (item 994). A `pkg#task` entry may name this
|
|
759
|
+
* project or a dependency, which the schema cannot tell apart, so it pairs
|
|
760
|
+
* with any form whose task half matches. `[]` is the explicit way to
|
|
761
|
+
* decouple.
|
|
621
762
|
*/
|
|
622
763
|
function assertFilterNamesDeclaredDeps(
|
|
623
764
|
filters: readonly string[],
|
|
624
765
|
dependsOn: readonly string[] | undefined,
|
|
625
766
|
where: string,
|
|
626
767
|
): void {
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
768
|
+
const declared = (dependsOn ?? []).map(specForm)
|
|
769
|
+
const matches = (pattern: string, name: string): boolean =>
|
|
770
|
+
pattern.includes('*') ? taskPatternRegExp(pattern).test(name) : pattern === name
|
|
771
|
+
const named = (f: SpecForm): boolean =>
|
|
772
|
+
declared.some((d) => {
|
|
773
|
+
if (!matches(d.task, f.task)) return false
|
|
774
|
+
if (d.form === 'cross' && f.form === 'cross') {
|
|
775
|
+
return f.project.includes('*') || matches(d.project, f.project)
|
|
776
|
+
}
|
|
777
|
+
return d.form === f.form || d.form === 'cross' || f.form === 'cross'
|
|
778
|
+
})
|
|
635
779
|
for (const raw of filters) {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
if (
|
|
780
|
+
// `*` and `^*` need no arm of their own: their task half is `*`, which
|
|
781
|
+
// the wildcard skip below takes (item 678).
|
|
782
|
+
if (raw.startsWith('!')) continue
|
|
783
|
+
const f = specForm(raw)
|
|
784
|
+
if (f.task.length === 0 || f.task.includes('*') || named(f)) continue
|
|
639
785
|
throw new UserError(
|
|
640
786
|
`${where}.cache.inputs.tasks: "${raw}" names no task in ${where}.dependsOn ` +
|
|
641
787
|
`(${declared.length === 0 ? 'none declared' : dependsOn!.map((d) => `'${d}'`).join(', ')}) — ` +
|
|
642
788
|
`it would match nothing and fold no upstream hash, decoupling the task from its ` +
|
|
643
|
-
`dependencies.
|
|
789
|
+
`dependencies. \`name\` is this project's task, \`^name\` its dependencies', ` +
|
|
790
|
+
`\`pkg#name\` one project's. Fix the name, or use [] to decouple on purpose.`,
|
|
644
791
|
)
|
|
645
792
|
}
|
|
646
793
|
}
|
|
647
794
|
|
|
795
|
+
type SpecForm =
|
|
796
|
+
| { form: 'self' | 'deps'; task: string }
|
|
797
|
+
| { form: 'cross'; project: string; task: string }
|
|
798
|
+
|
|
799
|
+
/** A `dependsOn` / filter entry's form, as `parseDependencySpec` splits it. */
|
|
800
|
+
function specForm(spec: string): SpecForm {
|
|
801
|
+
if (spec.startsWith('^')) return { form: 'deps', task: spec.slice(1) }
|
|
802
|
+
const hash = spec.indexOf('#')
|
|
803
|
+
if (hash === -1) return { form: 'self', task: spec }
|
|
804
|
+
return { form: 'cross', project: spec.slice(0, hash), task: spec.slice(hash + 1) }
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* A name an environment can hold. An `=` splits at the first one, so
|
|
809
|
+
* `define: { 'A=B': 'x' }` gave the child `A` with the value `B=x`; `''`
|
|
810
|
+
* was dropped; a NUL failed the spawn with a hint about exit 127 (item
|
|
811
|
+
* 999). Refused at load, where the config is named.
|
|
812
|
+
*/
|
|
813
|
+
function isEnvName(name: unknown): name is string {
|
|
814
|
+
return typeof name === 'string' && name.length > 0 && !name.includes('=') && !name.includes('\0')
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* A task name `dependsOn`, `cache.inputs.tasks` and the CLI can name. Each
|
|
819
|
+
* refused shape loaded and could not be referenced: `x#y` read as project
|
|
820
|
+
* `x`, `^gen` as every dependency's `gen`, a `*` as a pattern, `''` ran
|
|
821
|
+
* under `--all` as `a#` and `vx run a#` refused it, and a padded name was
|
|
822
|
+
* trimmed away by the reader (item 1000).
|
|
823
|
+
*/
|
|
824
|
+
function assertTaskName(name: string, configPath: string): void {
|
|
825
|
+
const why =
|
|
826
|
+
name.trim().length === 0
|
|
827
|
+
? 'is empty'
|
|
828
|
+
: name.trim() !== name
|
|
829
|
+
? 'has surrounding whitespace'
|
|
830
|
+
: name.includes('#')
|
|
831
|
+
? "holds '#', which separates a project from its task"
|
|
832
|
+
: name.includes('*')
|
|
833
|
+
? "holds '*', which makes it a pattern"
|
|
834
|
+
: name.startsWith('^') || name.startsWith('!')
|
|
835
|
+
? `starts with '${name[0]}', which names dependencies' tasks or negates`
|
|
836
|
+
: null
|
|
837
|
+
if (why === null) return
|
|
838
|
+
throw new UserError(
|
|
839
|
+
`${configPath}: task name ${JSON.stringify(name)} ${why} — dependsOn, cache.inputs.tasks and ` +
|
|
840
|
+
`the CLI could not name it. Rename the task.`,
|
|
841
|
+
)
|
|
842
|
+
}
|
|
843
|
+
|
|
648
844
|
/** The graph's `*`-only task glob (`compileTaskPattern`), mirrored: `*` is the sole metacharacter. */
|
|
649
845
|
function taskPatternRegExp(pattern: string): RegExp {
|
|
650
846
|
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
|