@vzn/vx 0.0.596 → 0.0.597
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/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +31 -15
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +87 -32
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +2 -1
- package/src/cli/cache.ts +2 -2
- package/src/cli/foreign-flags.ts +56 -2
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +6 -3
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/run.ts +42 -15
- package/src/cli/select.ts +6 -3
- package/src/cli/show.ts +8 -2
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch.ts +14 -7
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +21 -0
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +89 -18
- package/src/exec/runner.ts +74 -36
- package/src/exec/sandbox-paths.ts +11 -0
- package/src/exec/sandbox-runtime.ts +168 -49
- package/src/exec/sandbox-violations.ts +59 -6
- package/src/graph/scheduler.ts +2 -2
- package/src/graph/task-graph.ts +145 -33
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +49 -26
- package/src/orchestrator/failure-mode.ts +38 -29
- package/src/orchestrator/framed-output.ts +5 -5
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/logger.ts +10 -3
- package/src/orchestrator/miss-save.ts +7 -11
- package/src/orchestrator/plan.ts +4 -20
- package/src/orchestrator/run-artifacts.ts +2 -2
- package/src/orchestrator/run-lock.ts +25 -24
- package/src/orchestrator/run.ts +2 -1
- package/src/orchestrator/sandbox-request.ts +31 -9
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +5 -9
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +1 -1
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-eval.ts +27 -6
- package/src/workspace/config-imports.ts +8 -55
- package/src/workspace/config-schema.ts +30 -6
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/project-loader.ts +15 -2
- package/src/workspace/workspace.ts +36 -14
|
@@ -264,7 +264,7 @@ export function formatSummarySection(
|
|
|
264
264
|
let spread = ''
|
|
265
265
|
if (stats.spread !== null && stats.spread.count > 0) {
|
|
266
266
|
const { maxMs, minMs, sumMs, count } = stats.spread
|
|
267
|
-
spread = ` ${dim(`\u00b7 max ${
|
|
267
|
+
spread = ` ${dim(`\u00b7 max ${formatElapsed(maxMs)} \u00b7 avg ${formatElapsed(sumMs / count)} \u00b7 min ${formatElapsed(minMs)}`)}`
|
|
268
268
|
}
|
|
269
269
|
// Run-shape footer (final summary only): worker pool + cache mode,
|
|
270
270
|
// grouped with time under a blank line below the meters.
|
|
@@ -280,15 +280,15 @@ export function formatSummarySection(
|
|
|
280
280
|
// (2026-09-15), so the line says it is a sum.
|
|
281
281
|
if (stats.held !== undefined && stats.held.count > 0) {
|
|
282
282
|
info.push(
|
|
283
|
-
`admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${
|
|
283
|
+
`admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${formatElapsed(stats.held.sumMs)} in all`,
|
|
284
284
|
)
|
|
285
285
|
}
|
|
286
|
-
lines.push('', row('info', join(info)), row('time', `${
|
|
286
|
+
lines.push('', row('info', join(info)), row('time', `${formatElapsed(totalMs)}${spread}`))
|
|
287
287
|
// The run in one line, last, where the eye lands: what a Turbo user
|
|
288
288
|
// reads first in its own summary (tasks, cached, time), in vx's words.
|
|
289
289
|
if (stats.total > 0) lines.push(row('result', resultLine(stats, hits, totalMs, colors)))
|
|
290
290
|
} else {
|
|
291
|
-
lines.push('', row('time', `${
|
|
291
|
+
lines.push('', row('time', `${formatElapsed(totalMs)}${spread}`))
|
|
292
292
|
}
|
|
293
293
|
return lines
|
|
294
294
|
}
|
|
@@ -320,7 +320,7 @@ function resultLine(
|
|
|
320
320
|
: `${hits} cached (${Math.floor((hits / cacheable) * 100)}%)`,
|
|
321
321
|
)
|
|
322
322
|
if (noCache > 0) parts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
|
|
323
|
-
parts.push(
|
|
323
|
+
parts.push(formatElapsed(totalMs))
|
|
324
324
|
return parts.join(` ${paint('', '\u00b7', colors, { dim: true })} `)
|
|
325
325
|
}
|
|
326
326
|
|
|
@@ -426,7 +426,3 @@ export function neverStarted(
|
|
|
426
426
|
): boolean {
|
|
427
427
|
return o.status === 'aborted' && o.wallclockStartNs === undefined
|
|
428
428
|
}
|
|
429
|
-
|
|
430
|
-
export function formatDuration(ms: number): string {
|
|
431
|
-
return formatElapsed(ms)
|
|
432
|
-
}
|
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
WORKSPACE_OUTPUT_PREFIX,
|
|
17
17
|
} from '../cache/index.js'
|
|
18
18
|
import type { InputFile, TaskInputs } from '../exec/index.js'
|
|
19
|
-
import type
|
|
20
|
-
import {
|
|
19
|
+
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
20
|
+
import { isUserError, relPosix, secretMask, span, UserError, xxh3hex } from '../util/index.js'
|
|
21
21
|
import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -296,6 +296,13 @@ async function resolveKeyInput(
|
|
|
296
296
|
projectFilesCache: args.hashCache.projectFiles,
|
|
297
297
|
}
|
|
298
298
|
: {}),
|
|
299
|
+
}).catch((err: unknown) => {
|
|
300
|
+
// A failed probe's message quotes its command and output, and the probe
|
|
301
|
+
// runs in vx's own environment: `echo $NPM_TOKEN` printed the token in
|
|
302
|
+
// the task's stream and `--dry`'s stderr.
|
|
303
|
+
if (!isUserError(err)) throw err
|
|
304
|
+
const secrets = secretMask([process.env, cfg.exec?.env?.define], cfg.exec?.env?.secret)
|
|
305
|
+
throw secrets === null ? err : new UserError(secrets.mask(err.message))
|
|
299
306
|
})
|
|
300
307
|
|
|
301
308
|
const upstreamPairs = filterUpstreamHashes(
|
|
@@ -338,7 +345,10 @@ async function resolveKeyInput(
|
|
|
338
345
|
fileHashes,
|
|
339
346
|
)
|
|
340
347
|
|
|
341
|
-
|
|
348
|
+
// Args after `--` reach a requested command; the default build (a keyed
|
|
349
|
+
// group) runs none, and folded, they moved every key that folds its own.
|
|
350
|
+
const effectiveForwardArgs =
|
|
351
|
+
args.node.requested && !isGroupTask(args.node) ? (args.forwardArgs ?? []) : []
|
|
342
352
|
|
|
343
353
|
const input: CacheKeyInput = {
|
|
344
354
|
taskId: args.node.id,
|
|
@@ -28,6 +28,17 @@ export function keyUpstream(node: TaskNode, upstream: TaskOutcome[]): TaskOutcom
|
|
|
28
28
|
return node.excludedUpstream === undefined ? keyed : [...keyed, ...node.excludedUpstream]
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** A dependency as a key reads it: only `node` and `hash` matter. */
|
|
32
|
+
export function keyedOutcome(node: TaskNode, hash: string | undefined): TaskOutcome {
|
|
33
|
+
return {
|
|
34
|
+
node,
|
|
35
|
+
status: 'success',
|
|
36
|
+
exitCode: 0,
|
|
37
|
+
durationMs: 0,
|
|
38
|
+
...(hash !== undefined ? { hash } : {}),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
/** `node.deps` less its ordering-only edges: the dependencies a key may fold. */
|
|
32
43
|
export function keyedDeps(node: TaskNode): readonly string[] {
|
|
33
44
|
const order = node.orderOnly
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Did this process start with SIGHUP ignored? `nohup` and a supervisor
|
|
2
|
+
// that wants a job to outlive its terminal say so through the inherited
|
|
3
|
+
// disposition, and a `process.on('SIGHUP')` replaces it: vx under nohup
|
|
4
|
+
// stopped its run and exited 129 when the terminal closed. The POSIX idiom
|
|
5
|
+
// is to ask before installing; Node has no API for it, so Linux reads
|
|
6
|
+
// `/proc/self/status` (self is this process in any pid namespace's procfs)
|
|
7
|
+
// and macOS asks `sigaction` itself. Read once, before the first handler.
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { dlopen, FFIType, ptr } from 'bun:ffi'
|
|
11
|
+
|
|
12
|
+
let ignored: boolean | undefined
|
|
13
|
+
|
|
14
|
+
export function hangupIgnored(): boolean {
|
|
15
|
+
ignored ??= read()
|
|
16
|
+
return ignored
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function read(): boolean {
|
|
20
|
+
try {
|
|
21
|
+
if (process.platform === 'linux') {
|
|
22
|
+
const mask = /^SigIgn:\s*([0-9a-f]+)$/m.exec(readFileSync('/proc/self/status', 'utf8'))
|
|
23
|
+
return mask !== null && (BigInt(`0x${mask[1]}`) & 1n) === 1n
|
|
24
|
+
}
|
|
25
|
+
if (process.platform === 'darwin') return darwinIgnored()
|
|
26
|
+
} catch {}
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function darwinIgnored(): boolean {
|
|
31
|
+
// Opened only here: bun:ffi compiles a trampoline per call site, ~1 ms.
|
|
32
|
+
const lib = dlopen('/usr/lib/libSystem.B.dylib', {
|
|
33
|
+
sigaction: { args: [FFIType.i32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
|
34
|
+
})
|
|
35
|
+
try {
|
|
36
|
+
// `struct sigaction` opens with the handler; SIG_IGN is 1. SIGHUP is 1.
|
|
37
|
+
const old = new BigUint64Array(4)
|
|
38
|
+
return lib.symbols.sigaction(1, null, ptr(old)) === 0 && old[0] === 1n
|
|
39
|
+
} finally {
|
|
40
|
+
lib.close()
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/util/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ export {
|
|
|
43
43
|
relPosix,
|
|
44
44
|
splitNegations,
|
|
45
45
|
staticPrefix,
|
|
46
|
+
stripTrailingSlash,
|
|
46
47
|
anyTaskGlob,
|
|
47
48
|
isInstalledPath,
|
|
48
49
|
taskGlob,
|
|
@@ -72,6 +73,7 @@ export { CORE_VERBS, MOVED_VERBS } from './verbs.js'
|
|
|
72
73
|
export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.js'
|
|
73
74
|
export { executablePath, shellArgv, taskShell } from './which.js'
|
|
74
75
|
export { procfsIsOwn } from './procfs.js'
|
|
76
|
+
export { hangupIgnored } from './hangup.js'
|
|
75
77
|
export { realPath } from './real-path.js'
|
|
76
78
|
export {
|
|
77
79
|
maskedCommand,
|
package/src/util/paths.ts
CHANGED
|
@@ -84,7 +84,10 @@ export interface AffectedArgs {
|
|
|
84
84
|
* (item 954). The `--affected` sugar's graph walk has already staged every
|
|
85
85
|
* config, so there it costs nothing more.
|
|
86
86
|
*/
|
|
87
|
-
workspaceGlobOwners?: (
|
|
87
|
+
workspaceGlobOwners?: (
|
|
88
|
+
changedPaths: readonly string[],
|
|
89
|
+
nested: readonly string[],
|
|
90
|
+
) => Promise<Iterable<string>>
|
|
88
91
|
/**
|
|
89
92
|
* The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
|
|
90
93
|
* answer for a change to one. Resolved lazily: loading the workspace
|
|
@@ -142,6 +145,12 @@ export interface AffectedChanges {
|
|
|
142
145
|
/** Project → its changed paths, relative to the project's directory. */
|
|
143
146
|
paths: ReadonlyMap<string, readonly string[]>
|
|
144
147
|
whole: ReadonlySet<string>
|
|
148
|
+
/**
|
|
149
|
+
* The changed paths that are nested repositories (a submodule, an
|
|
150
|
+
* embedded repository), workspace-relative: each stands for every file
|
|
151
|
+
* in it, so a `workspaceFiles` glob that reaches under one is reached.
|
|
152
|
+
*/
|
|
153
|
+
nested?: readonly string[]
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
export async function affectedChanges(
|
|
@@ -221,16 +230,10 @@ export async function affectedChanges(
|
|
|
221
230
|
// submodules (`diff.ignoreSubmodules`, `submodule.<name>.ignore`), and
|
|
222
231
|
// then an edit inside one, or a committed bump, was no change here while
|
|
223
232
|
// the task's key moved (item 951).
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
'--relative',
|
|
229
|
-
'--name-only',
|
|
230
|
-
'-z',
|
|
231
|
-
'--end-of-options',
|
|
232
|
-
base,
|
|
233
|
-
]),
|
|
233
|
+
//
|
|
234
|
+
// `--raw` for each path's mode: a gitlink (160000) is a nested
|
|
235
|
+
// repository, one path standing for every file in it (`nested` below).
|
|
236
|
+
gitDiffPaths(args.workspaceRoot, base),
|
|
234
237
|
// `git diff` never reports untracked-but-not-ignored files, but input
|
|
235
238
|
// enumeration does (`git ls-files --cached --others --exclude-standard`),
|
|
236
239
|
// so a brand-new source file changes a task's cache key. Union it in or
|
|
@@ -244,9 +247,13 @@ export async function affectedChanges(
|
|
|
244
247
|
// file: re-running `vx lock` must not mark every project affected.
|
|
245
248
|
// An untracked file under `node_modules` is an install, which the input
|
|
246
249
|
// enumeration drops too (`isInstalledPath`); a tracked one is a source.
|
|
247
|
-
const changed = [...diffed, ...untracked.filter((s) => !isInstalledPath(s))].filter(
|
|
250
|
+
const changed = [...diffed.paths, ...untracked.filter((s) => !isInstalledPath(s))].filter(
|
|
248
251
|
(s) => s !== LOCKFILE_NAME,
|
|
249
252
|
)
|
|
253
|
+
// The nested repositories git reports as one path: a gitlink, or an
|
|
254
|
+
// untracked embedded repository (`dir/`, the only path git prints so).
|
|
255
|
+
const nested = new Set(diffed.gitlinks)
|
|
256
|
+
for (const rel of untracked) if (rel.endsWith('/')) nested.add(rel)
|
|
250
257
|
|
|
251
258
|
// A lockfile or workspace-definition change re-keys EVERY task, because the
|
|
252
259
|
// workspace fingerprint folds those files into every cache key. Mapping
|
|
@@ -316,6 +323,7 @@ export async function affectedChanges(
|
|
|
316
323
|
const owned = projectsContaining(
|
|
317
324
|
args.workspaceRoot,
|
|
318
325
|
changed,
|
|
326
|
+
nested,
|
|
319
327
|
args.projects,
|
|
320
328
|
realDirs,
|
|
321
329
|
perTask
|
|
@@ -371,15 +379,21 @@ export async function affectedChanges(
|
|
|
371
379
|
projects: args.projects,
|
|
372
380
|
changed,
|
|
373
381
|
skip: perTask ? new Set() : owned,
|
|
374
|
-
realDirs,
|
|
375
382
|
})) {
|
|
376
383
|
owned.add(name)
|
|
377
384
|
whole.add(name)
|
|
378
385
|
}
|
|
379
386
|
|
|
380
|
-
const
|
|
387
|
+
const nestedDirs = [...nested].map((rel) => rel.replace(/\/$/, ''))
|
|
388
|
+
const done = (): AffectedChanges => ({
|
|
389
|
+
projects: owned,
|
|
390
|
+
changed,
|
|
391
|
+
paths,
|
|
392
|
+
whole,
|
|
393
|
+
...(nestedDirs.length > 0 ? { nested: nestedDirs } : {}),
|
|
394
|
+
})
|
|
381
395
|
if (changed.length === 0 || args.workspaceGlobOwners === undefined) return done()
|
|
382
|
-
for (const name of await args.workspaceGlobOwners(changed)) owned.add(name)
|
|
396
|
+
for (const name of await args.workspaceGlobOwners(changed, nestedDirs)) owned.add(name)
|
|
383
397
|
return done()
|
|
384
398
|
}
|
|
385
399
|
|
|
@@ -645,6 +659,37 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
|
|
|
645
659
|
return stdout.split('\0').filter((s) => s.length > 0)
|
|
646
660
|
}
|
|
647
661
|
|
|
662
|
+
/**
|
|
663
|
+
* `git diff --raw -z <base>`'s paths, and those that are a gitlink on
|
|
664
|
+
* either side: one the change removed took its files with it. Each record
|
|
665
|
+
* is `:<mode> <mode> <oid> <oid> <status>` then its path (one path:
|
|
666
|
+
* `--no-renames`).
|
|
667
|
+
*/
|
|
668
|
+
async function gitDiffPaths(
|
|
669
|
+
workspaceRoot: string,
|
|
670
|
+
base: string,
|
|
671
|
+
): Promise<{ paths: string[]; gitlinks: string[] }> {
|
|
672
|
+
const fields = await gitPaths(workspaceRoot, [
|
|
673
|
+
'diff',
|
|
674
|
+
'--no-renames',
|
|
675
|
+
'--ignore-submodules=none',
|
|
676
|
+
'--relative',
|
|
677
|
+
'--raw',
|
|
678
|
+
'-z',
|
|
679
|
+
'--end-of-options',
|
|
680
|
+
base,
|
|
681
|
+
])
|
|
682
|
+
const paths: string[] = []
|
|
683
|
+
const gitlinks: string[] = []
|
|
684
|
+
for (let i = 0; i + 1 < fields.length; i += 2) {
|
|
685
|
+
const rel = fields[i + 1]!
|
|
686
|
+
paths.push(rel)
|
|
687
|
+
const head = fields[i]!
|
|
688
|
+
if (head.startsWith('160000 ', 1) || head.startsWith('160000 ', 8)) gitlinks.push(rel)
|
|
689
|
+
}
|
|
690
|
+
return { paths, gitlinks }
|
|
691
|
+
}
|
|
692
|
+
|
|
648
693
|
/**
|
|
649
694
|
* Resolve the default base for `--affected` with no explicit value.
|
|
650
695
|
* Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
|
|
@@ -826,17 +871,11 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
|
826
871
|
)
|
|
827
872
|
}
|
|
828
873
|
|
|
829
|
-
function isDirectory(abs: string): boolean {
|
|
830
|
-
try {
|
|
831
|
-
return statSync(abs).isDirectory()
|
|
832
|
-
} catch {
|
|
833
|
-
return false
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
874
|
function projectsContaining(
|
|
838
875
|
workspaceRoot: string,
|
|
839
876
|
changedRelPaths: readonly string[],
|
|
877
|
+
/** The changed paths that are nested repositories (`nested` in `affectedChanges`). */
|
|
878
|
+
nested: ReadonlySet<string>,
|
|
840
879
|
projects: readonly ProjectMeta[],
|
|
841
880
|
realDirs: ReadonlyMap<string, string>,
|
|
842
881
|
/** Told each owned path relative to its project, and each nested repository's projects. */
|
|
@@ -868,37 +907,35 @@ function projectsContaining(
|
|
|
868
907
|
}
|
|
869
908
|
const owned = new Set<string>()
|
|
870
909
|
for (const rel of changedRelPaths) {
|
|
910
|
+
const repo = nested.has(rel)
|
|
871
911
|
let dir = path.resolve(workspaceRoot, rel).normalize('NFC')
|
|
872
|
-
let hit = false
|
|
873
912
|
for (;;) {
|
|
874
913
|
const name = dirToName.get(dir)
|
|
875
914
|
if (name !== undefined) {
|
|
876
915
|
owned.add(name)
|
|
877
916
|
on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')))
|
|
878
|
-
|
|
917
|
+
// Its files are the project's inputs, and none of them is this
|
|
918
|
+
// path: a task's globs cannot say whether they reach inside.
|
|
919
|
+
if (repo) on?.repo(name)
|
|
879
920
|
break
|
|
880
921
|
}
|
|
881
922
|
const parent = path.dirname(dir)
|
|
882
923
|
if (parent === dir) break // reached the filesystem root
|
|
883
924
|
dir = parent
|
|
884
925
|
}
|
|
885
|
-
if (
|
|
886
|
-
// A
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
if (!dir.startsWith(prefix)) continue
|
|
899
|
-
owned.add(name)
|
|
900
|
-
on?.repo(name)
|
|
901
|
-
}
|
|
926
|
+
if (!repo) continue
|
|
927
|
+
// A submodule whose checkout moved or is dirty (`vendor/sub`), an
|
|
928
|
+
// embedded repository left untracked (`vendor/nested/`): the workspace
|
|
929
|
+
// repository sees the nested one as that single path, so a change
|
|
930
|
+
// inside is a change to it, and every project under it is affected.
|
|
931
|
+
// Its own enumeration already keys those projects on their files
|
|
932
|
+
// (git-inputs.ts); without this, `--affected` after an edit inside
|
|
933
|
+
// selected none of them.
|
|
934
|
+
const prefix = path.resolve(workspaceRoot, rel).normalize('NFC') + path.sep
|
|
935
|
+
for (const [dir, name] of dirToName) {
|
|
936
|
+
if (!dir.startsWith(prefix)) continue
|
|
937
|
+
owned.add(name)
|
|
938
|
+
on?.repo(name)
|
|
902
939
|
}
|
|
903
940
|
}
|
|
904
941
|
return owned
|
|
@@ -185,12 +185,16 @@ self.onmessage = async (e) => {
|
|
|
185
185
|
: thrown
|
|
186
186
|
const report = changed()
|
|
187
187
|
umaskBack()
|
|
188
|
+
// A thrown string or plain object has no message, and String() of a
|
|
189
|
+
// null-prototype object throws here, inside the catch.
|
|
190
|
+
const isError = err !== null && typeof err === 'object' && typeof err.message === 'string'
|
|
188
191
|
postMessage({
|
|
189
192
|
id,
|
|
190
193
|
ok: false,
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
+
thrown: isError ? null : globalThis.Bun.inspect(err, { compact: true }),
|
|
195
|
+
name: isError ? (err.name ?? 'Error') : 'Error',
|
|
196
|
+
message: isError ? err.message : '',
|
|
197
|
+
stack: isError ? (err.stack ?? null) : null,
|
|
194
198
|
changed: report,
|
|
195
199
|
position:
|
|
196
200
|
err?.position && typeof err.position === 'object'
|
|
@@ -214,6 +218,8 @@ interface WorkerReply {
|
|
|
214
218
|
nonJson: NonJsonValue[]
|
|
215
219
|
name: string
|
|
216
220
|
message: string
|
|
221
|
+
/** What a config threw that is not an Error, as `thrownValueMessage` shows it; null for an Error. */
|
|
222
|
+
thrown?: string | null
|
|
217
223
|
stack: string | null
|
|
218
224
|
/** A transpile error's location — `BuildMessage.position`, trimmed to what the loader reads. */
|
|
219
225
|
position: { file?: string; line?: number; column?: number } | null
|
|
@@ -222,6 +228,16 @@ interface WorkerReply {
|
|
|
222
228
|
interface Pending {
|
|
223
229
|
resolve: (reply: WorkerReply) => void
|
|
224
230
|
reject: (err: Error) => void
|
|
231
|
+
configPath: string
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A config threw something that is not an Error: `throw 'no'` printed
|
|
236
|
+
* `vx: no`, naming no file, and a null-prototype object crashed vx's own
|
|
237
|
+
* error printer with a stack.
|
|
238
|
+
*/
|
|
239
|
+
export function thrownValueMessage(kind: string, configPath: string, shown: string): string {
|
|
240
|
+
return `${kind} config ${configPath} threw ${shown}, which is not an Error`
|
|
225
241
|
}
|
|
226
242
|
|
|
227
243
|
/**
|
|
@@ -236,10 +252,11 @@ export function evalBudgetMs(): number {
|
|
|
236
252
|
// BOUND on a worker that may be wedged, with no "no limit" reading. Falls
|
|
237
253
|
// back rather than clamping, because honouring ~24.8 days would hang
|
|
238
254
|
// `vx watch` forever on a worker the OS killed — and unbounded makes it 1 ms,
|
|
239
|
-
// so EVERY config load times out instead. Both ends break the same feature
|
|
255
|
+
// so EVERY config load times out instead. Both ends break the same feature,
|
|
256
|
+
// and `0` is the second end: it fired on the next tick (D-157).
|
|
240
257
|
if (raw !== undefined && /^[0-9]+$/.test(raw)) {
|
|
241
258
|
const n = Number(raw)
|
|
242
|
-
if (n <= MAX_TIMEOUT_MS) return n
|
|
259
|
+
if (n > 0 && n <= MAX_TIMEOUT_MS) return n
|
|
243
260
|
}
|
|
244
261
|
return 30_000
|
|
245
262
|
}
|
|
@@ -287,6 +304,10 @@ function acquireWorker(): Worker {
|
|
|
287
304
|
p.resolve(msg)
|
|
288
305
|
return
|
|
289
306
|
}
|
|
307
|
+
if (typeof msg.thrown === 'string') {
|
|
308
|
+
p.reject(new UserError(thrownValueMessage('Project', p.configPath, msg.thrown)))
|
|
309
|
+
return
|
|
310
|
+
}
|
|
290
311
|
// Rebuild the error the config actually threw. Name, message, stack
|
|
291
312
|
// and a transpile error's position all survive the hop, so a broken
|
|
292
313
|
// config reports the same text it would from an in-process import.
|
|
@@ -365,7 +386,7 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
|
|
|
365
386
|
// evaluation costs, so it can only fire on a genuine wedge.
|
|
366
387
|
const budget = evalBudgetMs()
|
|
367
388
|
const reply = await new Promise<WorkerReply>((resolve, reject) => {
|
|
368
|
-
pending.set(id, { resolve, reject })
|
|
389
|
+
pending.set(id, { resolve, reject, configPath })
|
|
369
390
|
timer = setTimeout(() => {
|
|
370
391
|
rejectAll(new Error(`config worker did not answer within ${budget}ms`))
|
|
371
392
|
if (worker !== null) {
|
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
// the lockfile moves, which the workspace fingerprint already covers.
|
|
21
21
|
// A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
|
|
22
22
|
// from disk (D-27).
|
|
23
|
-
// -
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
23
|
+
// - One reversed BFS from every config at once, each file read once
|
|
24
|
+
// however many configs reach it. It descends through every file in
|
|
25
|
+
// the workspace, another project's included: a site's `vx.config.ts`
|
|
26
|
+
// importing `../core/src/index.ts` evaluates what that file imports
|
|
27
|
+
// too, so an edit there moves the site's key. The walk stopped at the
|
|
28
|
+
// first file of another project, and containment selected core alone
|
|
29
|
+
// while the site's key moved (X-128).
|
|
30
30
|
|
|
31
31
|
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
32
32
|
import { realpath } from 'node:fs/promises'
|
|
@@ -393,32 +393,6 @@ export interface ConfigImportOwnersArgs {
|
|
|
393
393
|
changed: readonly string[]
|
|
394
394
|
/** Already-selected projects; their configs need no scan. */
|
|
395
395
|
skip: ReadonlySet<string>
|
|
396
|
-
/**
|
|
397
|
-
* Each project dir's realpath, when the caller already has them: the
|
|
398
|
-
* containment pass needs the same answers, and asking twice cost 5,000
|
|
399
|
-
* realpaths at 5,000 projects (D-24).
|
|
400
|
-
*/
|
|
401
|
-
realDirs?: ReadonlyMap<string, string>
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Project dir → name, REALPATH'D — which is why this cannot reuse the index
|
|
406
|
-
* the containment pass builds. `Bun.resolveSync` hands back realpath'd
|
|
407
|
-
* targets, and on darwin a workspace under `os.tmpdir()` lives at
|
|
408
|
-
* `/var/folders/…` while its realpath is `/private/var/folders/…`; comparing
|
|
409
|
-
* the two matches nothing and fails exactly like "found no imports".
|
|
410
|
-
*/
|
|
411
|
-
async function realDirIndex(
|
|
412
|
-
projects: readonly ProjectMeta[],
|
|
413
|
-
known: ReadonlyMap<string, string> | undefined,
|
|
414
|
-
): Promise<Map<string, string>> {
|
|
415
|
-
const out = new Map<string, string>()
|
|
416
|
-
await Promise.all(
|
|
417
|
-
projects.map(async (p) => {
|
|
418
|
-
out.set(known?.get(p.dir) ?? (await realpath(p.dir).catch(() => p.dir)), p.name)
|
|
419
|
-
}),
|
|
420
|
-
)
|
|
421
|
-
return out
|
|
422
396
|
}
|
|
423
397
|
|
|
424
398
|
const TS_EXT = new Set(['.ts', '.mts', '.cts'])
|
|
@@ -426,18 +400,6 @@ const TS_EXT = new Set(['.ts', '.mts', '.cts'])
|
|
|
426
400
|
/** The extensions Bun tries for a specifier that names none. */
|
|
427
401
|
const RESOLVED_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']
|
|
428
402
|
|
|
429
|
-
/** The deepest project containing `file`, or undefined when none does. */
|
|
430
|
-
function ownerOf(file: string, dirToName: ReadonlyMap<string, string>): string | undefined {
|
|
431
|
-
let dir = path.dirname(file)
|
|
432
|
-
for (;;) {
|
|
433
|
-
const name = dirToName.get(dir)
|
|
434
|
-
if (name !== undefined) return name
|
|
435
|
-
const parent = path.dirname(dir)
|
|
436
|
-
if (parent === dir) return undefined
|
|
437
|
-
dir = parent
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
403
|
/** Projects whose config file transitively imports one of `changed`. */
|
|
442
404
|
export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set<string>> {
|
|
443
405
|
const selected = new Set<string>()
|
|
@@ -453,10 +415,6 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
453
415
|
}),
|
|
454
416
|
)
|
|
455
417
|
if (roots.size === 0) return selected
|
|
456
|
-
const dirToName = await realDirIndex(a.projects, a.realDirs)
|
|
457
|
-
// A root project owns every file no member owns: the shared tooling the
|
|
458
|
-
// walk descends through when the root is no project (D-41).
|
|
459
|
-
const rootOwner = dirToName.get(workspaceRoot)
|
|
460
418
|
|
|
461
419
|
// target → the files that import it. Reversed up front so one BFS from the
|
|
462
420
|
// changed set answers every root at once, instead of a walk per root.
|
|
@@ -486,18 +444,13 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
486
444
|
if (entry === null) continue
|
|
487
445
|
const { file, source } = entry
|
|
488
446
|
const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
|
|
489
|
-
const from = ownerOf(file, dirToName)
|
|
490
447
|
for (const target of scanLocalImports(source, path.dirname(file), loader, tsconfigs)) {
|
|
491
448
|
if (!target.startsWith(workspaceRoot + path.sep)) continue
|
|
492
449
|
if (target.split(path.sep).includes('node_modules')) continue
|
|
493
450
|
const list = importedBy.get(target)
|
|
494
451
|
if (list) list.push(file)
|
|
495
452
|
else importedBy.set(target, [file])
|
|
496
|
-
|
|
497
|
-
// the importer's own project's (a config split into `./tasks.mjs`
|
|
498
|
-
// that imports shared tooling): see the header.
|
|
499
|
-
const owner = ownerOf(target, dirToName)
|
|
500
|
-
if (owner === undefined || owner === rootOwner || owner === from) frontier.push(target)
|
|
453
|
+
frontier.push(target)
|
|
501
454
|
}
|
|
502
455
|
}
|
|
503
456
|
}
|
|
@@ -76,6 +76,16 @@ function validateRules(rules: unknown, configPath: string): void {
|
|
|
76
76
|
|
|
77
77
|
const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* A refused value as a message quotes it. A workspace file is evaluated
|
|
81
|
+
* in-process and no JSON rule precedes its schema, so a value here may be
|
|
82
|
+
* one `JSON.stringify` throws on (`30n`) or drops (a symbol): the refusal
|
|
83
|
+
* became a TypeError with a stack.
|
|
84
|
+
*/
|
|
85
|
+
function shown(v: unknown): string {
|
|
86
|
+
return typeof v === 'bigint' ? `${v}n` : (JSON.stringify(v) ?? String(v))
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
function validateRetention(retention: unknown, configPath: string): void {
|
|
80
90
|
const where = `${configPath}: \`cacheRetention\``
|
|
81
91
|
const field = (key: string) => `${configPath}: \`cacheRetention.${key}\``
|
|
@@ -93,12 +103,12 @@ function validateRetention(retention: unknown, configPath: string): void {
|
|
|
93
103
|
(typeof olderThan !== 'string' || parseDuration(olderThan) === null)
|
|
94
104
|
) {
|
|
95
105
|
throw new UserError(
|
|
96
|
-
`${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${
|
|
106
|
+
`${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${shown(olderThan)})`,
|
|
97
107
|
)
|
|
98
108
|
}
|
|
99
109
|
if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
|
|
100
110
|
throw new UserError(
|
|
101
|
-
`${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${
|
|
111
|
+
`${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${shown(maxSize)})`,
|
|
102
112
|
)
|
|
103
113
|
}
|
|
104
114
|
// The bounds `vx cache prune` refuses, for the same reason: each evicts
|
|
@@ -179,7 +189,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
179
189
|
throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
|
|
180
190
|
}
|
|
181
191
|
const verbOwners = new Map<string, string>()
|
|
182
|
-
const fileClaimants = new Map<string, string>()
|
|
192
|
+
const fileClaimants = new Map<string, { name: string; at: number }>()
|
|
183
193
|
for (const [i, p] of config.plugins.entries()) {
|
|
184
194
|
if (p === null || typeof p !== 'object') {
|
|
185
195
|
// Nx's nx.json lists plugins by module name (`'@nx/vite/plugin'`);
|
|
@@ -199,6 +209,13 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
199
209
|
// one whose `name` was overwritten after the stamp, is refused here —
|
|
200
210
|
// the one boundary every plugin crosses.
|
|
201
211
|
const pkg = (p as Record<symbol, unknown>)[PLUGIN_PACKAGE]
|
|
212
|
+
// An async factory called without `await` was told its plugin did not
|
|
213
|
+
// come from definePlugin — it had (D-159).
|
|
214
|
+
if (pkg === undefined && typeof (p as { then?: unknown }).then === 'function') {
|
|
215
|
+
throw new UserError(
|
|
216
|
+
`${configPath}: \`plugins[${i}]\` is a Promise — await the factory that returns the plugin`,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
202
219
|
if (typeof pkg !== 'string' || pkg.length === 0) {
|
|
203
220
|
throw new UserError(
|
|
204
221
|
`${configPath}: \`plugins[${i}]\` must come from definePlugin(import.meta, { … }) — a plugin's name is its package name`,
|
|
@@ -317,16 +334,23 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
317
334
|
/[/\\]/.test(file)
|
|
318
335
|
) {
|
|
319
336
|
throw new UserError(
|
|
320
|
-
`${configPath}: plugin '${plug.name}' claims fingerprint file ${
|
|
337
|
+
`${configPath}: plugin '${plug.name}' claims fingerprint file ${shown(file)}, which is not a file name at the workspace root`,
|
|
321
338
|
)
|
|
322
339
|
}
|
|
323
340
|
const owner = fileClaimants.get(file)
|
|
341
|
+
// One package twice (`plugins: [bun(), bun()]`) read "plugins 'x'
|
|
342
|
+
// and 'x' both claim" (D-158).
|
|
343
|
+
if (owner !== undefined && owner.name === plug.name) {
|
|
344
|
+
throw new UserError(
|
|
345
|
+
`${configPath}: plugin '${plug.name}' claims fingerprint file '${file}' twice (plugins[${owner.at}] and plugins[${i}]) — a file has one claimant; declare the plugin once`,
|
|
346
|
+
)
|
|
347
|
+
}
|
|
324
348
|
if (owner !== undefined) {
|
|
325
349
|
throw new UserError(
|
|
326
|
-
`${configPath}: plugins '${owner}' and '${plug.name}' both claim fingerprint file '${file}' — a file has one claimant`,
|
|
350
|
+
`${configPath}: plugins '${owner.name}' and '${plug.name}' both claim fingerprint file '${file}' — a file has one claimant`,
|
|
327
351
|
)
|
|
328
352
|
}
|
|
329
|
-
fileClaimants.set(file, plug.name)
|
|
353
|
+
fileClaimants.set(file, { name: plug.name, at: i })
|
|
330
354
|
}
|
|
331
355
|
}
|
|
332
356
|
// A plugin must contribute at least one capability or lifecycle hook
|
|
@@ -152,7 +152,8 @@ export async function frozenProjectConfigs(
|
|
|
152
152
|
): Promise<ProjectConfig[]> {
|
|
153
153
|
const entries = metas.map((meta) => {
|
|
154
154
|
const rel = relPosix(root, meta.configPath)
|
|
155
|
-
|
|
155
|
+
// Own entries only: `constructor` read Object's, "locked" at undefined (D-152).
|
|
156
|
+
const entry = Object.hasOwn(lock.projects, meta.name) ? lock.projects[meta.name] : undefined
|
|
156
157
|
if (entry !== undefined && entry.configPath !== rel) {
|
|
157
158
|
// A renamed or moved config: "has no entry for a" named an entry the
|
|
158
159
|
// lock holds, under its old path (D-88).
|