@vzn/vx 0.0.594 → 0.0.596
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 +1 -1
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +235 -174
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +122 -49
- package/src/cache/layer.ts +7 -5
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/policy.ts +5 -0
- package/src/cache/tar-stream.ts +1 -1
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +11 -15
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +26 -5
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/last.ts +4 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +60 -42
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +77 -13
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +16 -9
- package/src/cli/watch.ts +22 -15
- package/src/config.ts +26 -26
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +6 -2
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +12 -6
- package/src/exec/sandbox-runtime.ts +136 -44
- package/src/exec/sandbox-violations.ts +58 -11
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +29 -13
- package/src/graph/task-graph.ts +134 -25
- package/src/index.ts +6 -0
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/execute-task.ts +65 -20
- package/src/orchestrator/failure-mode.ts +8 -2
- package/src/orchestrator/framed-output.ts +3 -3
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- package/src/orchestrator/logger.ts +11 -6
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +2 -2
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +28 -4
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +39 -17
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +2 -1
- package/src/orchestrator/run.ts +72 -12
- package/src/orchestrator/sandbox-request.ts +65 -9
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +16 -5
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/util/index.ts +7 -1
- package/src/util/paths.ts +23 -3
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +8 -6
- package/src/workspace/config-schema.ts +74 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +76 -2
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +98 -20
|
@@ -176,6 +176,14 @@ export async function affectedChanges(
|
|
|
176
176
|
`("${since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
|
|
177
177
|
)
|
|
178
178
|
}
|
|
179
|
+
// `^main` is rev-list's exclusion, not a ref: merge-base refused it, it
|
|
180
|
+
// verified, and `git diff ^main` diffed from main itself, so changes
|
|
181
|
+
// only main made were selected (`^` is illegal in a ref name).
|
|
182
|
+
if (since.startsWith('^')) {
|
|
183
|
+
throw new UserError(
|
|
184
|
+
`git ref "${since}" is an exclusion, not a ref: pass the base alone ("${since.replace(/^\^+/, '') || 'HEAD'}").`,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
179
187
|
// Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
|
|
180
188
|
// on a branch whose base has moved on, `git diff <base>` reports every
|
|
181
189
|
// file OTHER people changed on the base (over-selection that defeats a
|
|
@@ -432,7 +440,17 @@ async function dependentsAtBase(
|
|
|
432
440
|
) {
|
|
433
441
|
continue
|
|
434
442
|
}
|
|
435
|
-
|
|
443
|
+
// Today's catalogs, or both graphs would differ on every `catalog:`
|
|
444
|
+
// entry. A catalog edit is a `pnpm-workspace.yaml` or root manifest
|
|
445
|
+
// edit, which the fingerprint widening and the root's own change answer.
|
|
446
|
+
const catalogs = projects[0]?.catalogs
|
|
447
|
+
byDir.set(dir, {
|
|
448
|
+
name: pkg.name,
|
|
449
|
+
dir,
|
|
450
|
+
packageJson: pkg,
|
|
451
|
+
configPath: null,
|
|
452
|
+
...(catalogs === undefined ? {} : { catalogs }),
|
|
453
|
+
})
|
|
436
454
|
if (nameNow.get(dir) !== pkg.name) gone.add(pkg.name)
|
|
437
455
|
}
|
|
438
456
|
const now = buildPackageGraph([...projects])
|
|
@@ -468,8 +486,10 @@ function parentsOfNewNested(
|
|
|
468
486
|
projects: readonly ProjectMeta[],
|
|
469
487
|
): Set<string> {
|
|
470
488
|
const out = new Set<string>()
|
|
489
|
+
// Keyed NFC: macOS git reports paths NFC (core.precomposeunicode) while a
|
|
490
|
+
// dir discovered by readdir keeps the spelling it was created with.
|
|
471
491
|
const dirToName = new Map<string, string>()
|
|
472
|
-
for (const p of projects) dirToName.set(p.dir, p.name)
|
|
492
|
+
for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
|
|
473
493
|
for (const [rel, bytes] of atBase) {
|
|
474
494
|
const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
|
|
475
495
|
if (!dirToName.has(dir)) continue
|
|
@@ -829,8 +849,10 @@ function projectsContaining(
|
|
|
829
849
|
// O(files · path-depth) instead of O(files · projects): independent of the
|
|
830
850
|
// project count, which is what a big --affected diff on a 1000-project repo
|
|
831
851
|
// pays for.
|
|
852
|
+
// Keyed NFC: macOS git reports paths NFC (core.precomposeunicode) while a
|
|
853
|
+
// dir discovered by readdir keeps the spelling it was created with.
|
|
832
854
|
const dirToName = new Map<string, string>()
|
|
833
|
-
for (const p of projects) dirToName.set(p.dir, p.name)
|
|
855
|
+
for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
|
|
834
856
|
// A member linked in from elsewhere in the tree (`pkgs/b -> ../ext/b`) is
|
|
835
857
|
// indexed by its link, and git reports its files at their real place
|
|
836
858
|
// (`ext/b/src/a.txt`), which resolved to no project: an edit there
|
|
@@ -841,18 +863,18 @@ function projectsContaining(
|
|
|
841
863
|
const real = realDirs.get(p.dir) ?? p.dir
|
|
842
864
|
const rel = path.relative(realRoot, real)
|
|
843
865
|
if (rel.startsWith('..') || path.isAbsolute(rel)) continue
|
|
844
|
-
const spelled = path.resolve(workspaceRoot, rel)
|
|
845
|
-
if (
|
|
866
|
+
const spelled = path.resolve(workspaceRoot, rel).normalize('NFC')
|
|
867
|
+
if (!dirToName.has(spelled)) dirToName.set(spelled, p.name)
|
|
846
868
|
}
|
|
847
869
|
const owned = new Set<string>()
|
|
848
870
|
for (const rel of changedRelPaths) {
|
|
849
|
-
let dir = path.resolve(workspaceRoot, rel)
|
|
871
|
+
let dir = path.resolve(workspaceRoot, rel).normalize('NFC')
|
|
850
872
|
let hit = false
|
|
851
873
|
for (;;) {
|
|
852
874
|
const name = dirToName.get(dir)
|
|
853
875
|
if (name !== undefined) {
|
|
854
876
|
owned.add(name)
|
|
855
|
-
on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel)))
|
|
877
|
+
on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')))
|
|
856
878
|
hit = true
|
|
857
879
|
break
|
|
858
880
|
}
|
|
@@ -871,7 +893,7 @@ function projectsContaining(
|
|
|
871
893
|
// this, `--affected` after an edit inside selected none of them.
|
|
872
894
|
const abs = path.resolve(workspaceRoot, rel)
|
|
873
895
|
if (isDirectory(abs)) {
|
|
874
|
-
const prefix = abs + path.sep
|
|
896
|
+
const prefix = abs.normalize('NFC') + path.sep
|
|
875
897
|
for (const [dir, name] of dirToName) {
|
|
876
898
|
if (!dir.startsWith(prefix)) continue
|
|
877
899
|
owned.add(name)
|
|
@@ -409,6 +409,16 @@ function keySeed(workspaceFingerprint: string): bigint {
|
|
|
409
409
|
|
|
410
410
|
const EXPLICIT_EXT = /\.(?:m?[jt]s|cjs|cts)$/
|
|
411
411
|
|
|
412
|
+
/**
|
|
413
|
+
* A file's identity as the key folds it. `hashFile` names an executable
|
|
414
|
+
* `100755:<oid>`, the slow path's `hashBytes` the bare oid; the mode moves
|
|
415
|
+
* no evaluation, and with it the warm key of an executable config never
|
|
416
|
+
* met the stored one, so every load read and re-keyed it.
|
|
417
|
+
*/
|
|
418
|
+
function contentIdentity(identity: string): string {
|
|
419
|
+
return identity.startsWith('100755:') ? identity.slice(7) : identity
|
|
420
|
+
}
|
|
421
|
+
|
|
412
422
|
/**
|
|
413
423
|
* The canonical directory Bun resolves `file`'s imports from: its REAL
|
|
414
424
|
* path's, so a config linked in from elsewhere imports its neighbours
|
|
@@ -482,7 +492,7 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
|
|
|
482
492
|
const identity = a.hashBytes
|
|
483
493
|
? a.hashBytes(bytes, file)
|
|
484
494
|
: a.hashFile
|
|
485
|
-
? await a.hashFile(file)
|
|
495
|
+
? contentIdentity(await a.hashFile(file))
|
|
486
496
|
: await hashOf(file, bytes)
|
|
487
497
|
h = xxh3(`${file}\0${identity}`, h)
|
|
488
498
|
closure.push(file)
|
|
@@ -542,6 +552,14 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
|
|
|
542
552
|
return { key: h.toString(16).padStart(16, '0'), closure, indexable }
|
|
543
553
|
}
|
|
544
554
|
|
|
555
|
+
// `hashFile` names a symlink by its target string (git's mode 120000), not
|
|
556
|
+
// the bytes the evaluation read, so no stored key can match a fold of it.
|
|
557
|
+
// Only a linked config with no relative import is indexed at all; with no
|
|
558
|
+
// warm key it joins the round lookup on its slow key instead of paying a
|
|
559
|
+
// single lookup and an index rewrite per load. The identity is from the
|
|
560
|
+
// lstat `hashFiles` already took: no syscall is added.
|
|
561
|
+
const LINK_IDENTITY = '120000:'
|
|
562
|
+
|
|
545
563
|
/**
|
|
546
564
|
* The warm path: the key for a config whose ordered closure the store
|
|
547
565
|
* remembers, from per-file identities alone — no read, no scan. The fold is
|
|
@@ -561,7 +579,10 @@ export async function configEvalKeyFromClosure(a: {
|
|
|
561
579
|
} catch {
|
|
562
580
|
return null
|
|
563
581
|
}
|
|
564
|
-
|
|
582
|
+
if (identities.some((id) => id.startsWith(LINK_IDENTITY))) return null
|
|
583
|
+
for (let i = 0; i < a.closure.length; i++) {
|
|
584
|
+
h = xxh3(`${a.closure[i]}\0${contentIdentity(identities[i]!)}`, h)
|
|
585
|
+
}
|
|
565
586
|
return h.toString(16).padStart(16, '0')
|
|
566
587
|
}
|
|
567
588
|
|
|
@@ -574,8 +595,8 @@ export function configEvalKeyFromIdentities(a: {
|
|
|
574
595
|
let h = keySeed(a.workspaceFingerprint)
|
|
575
596
|
for (const file of a.closure) {
|
|
576
597
|
const id = a.identities.get(file)
|
|
577
|
-
if (id === undefined) return null
|
|
578
|
-
h = xxh3(`${file}\0${id}`, h)
|
|
598
|
+
if (id === undefined || id.startsWith(LINK_IDENTITY)) return null
|
|
599
|
+
h = xxh3(`${file}\0${contentIdentity(id)}`, h)
|
|
579
600
|
}
|
|
580
601
|
return h.toString(16).padStart(16, '0')
|
|
581
602
|
}
|
|
@@ -20,9 +20,9 @@
|
|
|
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
|
-
// - Descend only through files owned by NO project,
|
|
23
|
+
// - Descend only through files owned by NO project, by a ROOT project
|
|
24
24
|
// (whose own files are the shared tooling that is otherwise unowned,
|
|
25
|
-
// D-41). A config reaching into another project (say a site's
|
|
25
|
+
// D-41), or by the importing file's own project. A config reaching into another project (say a site's
|
|
26
26
|
// `vx.config.ts` importing `../core/src/index.ts`) records that edge
|
|
27
27
|
// and STOPS there — following it would drag substantially all of that
|
|
28
28
|
// project's `src/` into the closure, and the containment channel
|
|
@@ -305,7 +305,7 @@ function selfReference(pkg: string, fromDir: string): boolean {
|
|
|
305
305
|
const manifest = path.join(dir, 'package.json')
|
|
306
306
|
if (existsSync(manifest)) {
|
|
307
307
|
try {
|
|
308
|
-
const json = JSON.parse(readFileSync(manifest, 'utf8')) as {
|
|
308
|
+
const json = JSON.parse(readFileSync(manifest, 'utf8').replace(/^\uFEFF/, '')) as {
|
|
309
309
|
name?: unknown
|
|
310
310
|
exports?: unknown
|
|
311
311
|
}
|
|
@@ -486,16 +486,18 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
486
486
|
if (entry === null) continue
|
|
487
487
|
const { file, source } = entry
|
|
488
488
|
const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
|
|
489
|
+
const from = ownerOf(file, dirToName)
|
|
489
490
|
for (const target of scanLocalImports(source, path.dirname(file), loader, tsconfigs)) {
|
|
490
491
|
if (!target.startsWith(workspaceRoot + path.sep)) continue
|
|
491
492
|
if (target.split(path.sep).includes('node_modules')) continue
|
|
492
493
|
const list = importedBy.get(target)
|
|
493
494
|
if (list) list.push(file)
|
|
494
495
|
else importedBy.set(target, [file])
|
|
495
|
-
// Descend ONLY through unowned files,
|
|
496
|
-
//
|
|
496
|
+
// Descend ONLY through unowned files, the root project's own, or
|
|
497
|
+
// the importer's own project's (a config split into `./tasks.mjs`
|
|
498
|
+
// that imports shared tooling): see the header.
|
|
497
499
|
const owner = ownerOf(target, dirToName)
|
|
498
|
-
if (owner === undefined || owner === rootOwner) frontier.push(target)
|
|
500
|
+
if (owner === undefined || owner === rootOwner || owner === from) frontier.push(target)
|
|
499
501
|
}
|
|
500
502
|
}
|
|
501
503
|
}
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
asTrees,
|
|
29
29
|
isLiteralPattern,
|
|
30
30
|
} from '../util/index.js'
|
|
31
|
-
import { compileNameGlob } from './filter.js'
|
|
31
|
+
import { compileNameGlob, parseFilter } from './filter.js'
|
|
32
32
|
import { nonJsonMessage, nonJsonPaths } from './json-data.js'
|
|
33
33
|
|
|
34
34
|
// Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
|
|
@@ -78,6 +78,7 @@ const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
|
|
|
78
78
|
|
|
79
79
|
function validateRetention(retention: unknown, configPath: string): void {
|
|
80
80
|
const where = `${configPath}: \`cacheRetention\``
|
|
81
|
+
const field = (key: string) => `${configPath}: \`cacheRetention.${key}\``
|
|
81
82
|
if (retention === null || typeof retention !== 'object' || Array.isArray(retention)) {
|
|
82
83
|
throw new UserError(`${where} must be { olderThan?: '30d', maxSize?: '10G' }`)
|
|
83
84
|
}
|
|
@@ -91,23 +92,27 @@ function validateRetention(retention: unknown, configPath: string): void {
|
|
|
91
92
|
olderThan !== undefined &&
|
|
92
93
|
(typeof olderThan !== 'string' || parseDuration(olderThan) === null)
|
|
93
94
|
) {
|
|
94
|
-
throw new UserError(
|
|
95
|
+
throw new UserError(
|
|
96
|
+
`${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${JSON.stringify(olderThan)})`,
|
|
97
|
+
)
|
|
95
98
|
}
|
|
96
99
|
if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
|
|
97
|
-
throw new UserError(
|
|
100
|
+
throw new UserError(
|
|
101
|
+
`${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${JSON.stringify(maxSize)})`,
|
|
102
|
+
)
|
|
98
103
|
}
|
|
99
104
|
// The bounds `vx cache prune` refuses, for the same reason: each evicts
|
|
100
105
|
// every entry, and here it would do so at the end of every run, the
|
|
101
106
|
// entries that run just saved included, so nothing ever hits (item 969).
|
|
102
107
|
if (olderThan !== undefined && parseDuration(olderThan) === 0) {
|
|
103
|
-
throw new UserError(`${
|
|
108
|
+
throw new UserError(`${field('olderThan')} of 0 evicts every entry after every run`)
|
|
104
109
|
}
|
|
105
110
|
if (maxSize !== undefined && parseSize(maxSize) === 0) {
|
|
106
|
-
throw new UserError(`${
|
|
111
|
+
throw new UserError(`${field('maxSize')} of 0 evicts every entry after every run`)
|
|
107
112
|
}
|
|
108
113
|
if (maxSize !== undefined && /^\d+$/.test(maxSize)) {
|
|
109
114
|
throw new UserError(
|
|
110
|
-
`${
|
|
115
|
+
`${field('maxSize')} '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
|
|
111
116
|
)
|
|
112
117
|
}
|
|
113
118
|
}
|
|
@@ -139,6 +144,11 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
139
144
|
) {
|
|
140
145
|
throw new UserError(`${configPath}: \`cacheDir\` is only whitespace — name a directory`)
|
|
141
146
|
}
|
|
147
|
+
// No path can carry one: the mkdir's argument error was reported as an
|
|
148
|
+
// unwritable workspace.
|
|
149
|
+
if (typeof config.cacheDir === 'string' && config.cacheDir.includes('\0')) {
|
|
150
|
+
throw new UserError(`${configPath}: \`cacheDir\` holds a NUL, which no path can carry`)
|
|
151
|
+
}
|
|
142
152
|
if (config.timeout !== undefined) {
|
|
143
153
|
if (
|
|
144
154
|
typeof config.timeout !== 'number' ||
|
|
@@ -151,8 +161,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
151
161
|
}
|
|
152
162
|
if (config.cacheRetention !== undefined) validateRetention(config.cacheRetention, configPath)
|
|
153
163
|
if (config.affectedBase !== undefined) {
|
|
154
|
-
// A leading '-' would reach git as an option, not a ref
|
|
155
|
-
|
|
164
|
+
// A leading '-' would reach git as an option, not a ref; a NUL fails the
|
|
165
|
+
// spawn with an argument error naming nothing in this file.
|
|
166
|
+
if (
|
|
167
|
+
typeof config.affectedBase !== 'string' ||
|
|
168
|
+
!/^[^-\s\0][^\s\0]*$/.test(config.affectedBase)
|
|
169
|
+
) {
|
|
156
170
|
throw new UserError(`${configPath}: \`affectedBase\` must be a git ref like 'origin/main'`)
|
|
157
171
|
}
|
|
158
172
|
}
|
|
@@ -230,6 +244,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
230
244
|
if (plug.commands === null || typeof plug.commands !== 'object') {
|
|
231
245
|
throw new UserError(`${configPath}: \`plugins[${i}].commands\` must be an object`)
|
|
232
246
|
}
|
|
247
|
+
// A list of commands loaded as the verbs `0`, `1`, … that help listed.
|
|
248
|
+
if (Array.isArray(plug.commands)) {
|
|
249
|
+
throw new UserError(
|
|
250
|
+
`${configPath}: \`plugins[${i}].commands\` must be an object keyed by verb, not an array`,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
233
253
|
for (const [verb, cmd] of Object.entries(plug.commands as Record<string, unknown>)) {
|
|
234
254
|
const c = cmd as { description?: unknown; run?: unknown } | null
|
|
235
255
|
if (
|
|
@@ -345,6 +365,15 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
345
365
|
) {
|
|
346
366
|
throw new UserError(`${configPath}: \`tags\` must be an array of non-empty strings`)
|
|
347
367
|
}
|
|
368
|
+
for (const tag of (tags ?? []) as string[]) {
|
|
369
|
+
const why = tagProblem(tag)
|
|
370
|
+
if (why !== null) {
|
|
371
|
+
throw new UserError(
|
|
372
|
+
`${configPath}: tag ${JSON.stringify(tag)} ${why} — \`--filter tag:<name>\` could not ` +
|
|
373
|
+
`name it. Rename the tag.`,
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
348
377
|
const tasks = config.tasks
|
|
349
378
|
if (tasks === undefined) return
|
|
350
379
|
if (typeof tasks !== 'object' || tasks === null || Array.isArray(tasks)) {
|
|
@@ -443,6 +472,8 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
443
472
|
`(got "${wild}") — list explicit env var names instead`,
|
|
444
473
|
)
|
|
445
474
|
}
|
|
475
|
+
for (const n of passThrough as string[])
|
|
476
|
+
assertShellName(n, `${where}.exec.env.passThrough`)
|
|
446
477
|
}
|
|
447
478
|
const secret = (env as { secret?: unknown }).secret
|
|
448
479
|
if (
|
|
@@ -469,6 +500,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
469
500
|
`${where}.exec.env.define: ${JSON.stringify(k)} is not an env var name (non-empty, no '=' or NUL)`,
|
|
470
501
|
)
|
|
471
502
|
}
|
|
503
|
+
assertShellName(k, `${where}.exec.env.define`)
|
|
472
504
|
if (typeof val !== 'string' || val.includes('\0')) {
|
|
473
505
|
throw new UserError(`${where}.exec.env.define.${k} must be a string with no NUL`)
|
|
474
506
|
}
|
|
@@ -1330,8 +1362,19 @@ function specForm(spec: string): SpecForm {
|
|
|
1330
1362
|
* A name an environment can hold. An `=` splits at the first one, so
|
|
1331
1363
|
* `define: { 'A=B': 'x' }` gave the child `A` with the value `B=x`; `''`
|
|
1332
1364
|
* was dropped; a NUL failed the spawn with a hint about exit 127 (item
|
|
1333
|
-
* 999).
|
|
1365
|
+
* 999). And the task runs under `sh -c`, whose dash (Linux) drops a
|
|
1366
|
+
* variable whose name is no shell identifier: `my.var` reached a task
|
|
1367
|
+
* under macOS's bash and nothing on Linux. Refused at load, where the
|
|
1368
|
+
* config is named.
|
|
1334
1369
|
*/
|
|
1370
|
+
function assertShellName(name: string, field: string): void {
|
|
1371
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
1372
|
+
throw new UserError(
|
|
1373
|
+
`${field}: ${JSON.stringify(name)} is not a shell variable name ([A-Za-z_][A-Za-z0-9_]*); sh would drop it before the task runs`,
|
|
1374
|
+
)
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1335
1378
|
function isEnvName(name: unknown): name is string {
|
|
1336
1379
|
return typeof name === 'string' && name.length > 0 && !name.includes('=') && !name.includes('\0')
|
|
1337
1380
|
}
|
|
@@ -1367,6 +1410,22 @@ export function taskNameProblem(name: string): string | null {
|
|
|
1367
1410
|
: null
|
|
1368
1411
|
}
|
|
1369
1412
|
|
|
1413
|
+
/**
|
|
1414
|
+
* Why `--filter tag:<tag>` cannot name `tag`, or null. Asked of the filter
|
|
1415
|
+
* parser itself, so a suffix it learns to read is refused here too: `v1...`
|
|
1416
|
+
* read as a dependency walk from `v1`, `v[2]` as `v` changed since ref `2`.
|
|
1417
|
+
*/
|
|
1418
|
+
function tagProblem(tag: string): string | null {
|
|
1419
|
+
if (tag.trim() !== tag) return 'has surrounding whitespace'
|
|
1420
|
+
if (tag.includes('*')) return "holds '*', which makes it a pattern"
|
|
1421
|
+
const f = parseFilter(`tag:${tag}`, '/')
|
|
1422
|
+
if (f.gitSince !== undefined) {
|
|
1423
|
+
return `ends in "[${f.gitSince}]", which a filter reads as a git range`
|
|
1424
|
+
}
|
|
1425
|
+
if (f.withDeps || f.onlyDeps) return "ends in '...', which a filter reads as a dependency walk"
|
|
1426
|
+
return null
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1370
1429
|
/**
|
|
1371
1430
|
* Refuse a non-empty glob list made up ENTIRELY of negations.
|
|
1372
1431
|
*
|
|
@@ -1488,6 +1547,12 @@ function assertStringArray(v: unknown, where: string): void {
|
|
|
1488
1547
|
if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
|
|
1489
1548
|
throw new UserError(`${where} must be an array of non-empty strings`)
|
|
1490
1549
|
}
|
|
1550
|
+
// No path, host or name holds a NUL; a write grant with one reached the
|
|
1551
|
+
// placeholder create as an internal error.
|
|
1552
|
+
const nul = (v as string[]).find((s) => s.includes('\0'))
|
|
1553
|
+
if (nul !== undefined) {
|
|
1554
|
+
throw new UserError(`${where}: ${JSON.stringify(nul)} holds a NUL byte`)
|
|
1555
|
+
}
|
|
1491
1556
|
}
|
|
1492
1557
|
|
|
1493
1558
|
/**
|
package/src/workspace/filter.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// {<dir>} same as ./<dir>
|
|
6
6
|
// // the workspace-root project only (Turbo's name for the root)
|
|
7
7
|
// tag:<pattern> the projects whose config `tags` hold a match (Nx's `tag:`)
|
|
8
|
+
// <dir>/<glob> a name with a `/` outside a scope that names no project:
|
|
9
|
+
// ./<dir>/<glob> (Nx's `--projects 'apps/*'`)
|
|
8
10
|
// <pattern>... pattern + its transitive workspace dependencies
|
|
9
11
|
// ...<pattern> pattern + its transitive workspace dependents
|
|
10
12
|
// <pattern>^... only the transitive deps of pattern (excluding the matched package)
|
|
@@ -58,6 +60,11 @@ export interface ParsedFilter {
|
|
|
58
60
|
exactDir?: true
|
|
59
61
|
/** `tag:<pattern>`: `matcher` is a glob over the projects' tags, not their names. */
|
|
60
62
|
tag?: true
|
|
63
|
+
/**
|
|
64
|
+
* `apps/*`: a name pattern holding a `/` outside a scope, read as
|
|
65
|
+
* `./apps/*` when it names no project, as Nx's `--projects` reads it.
|
|
66
|
+
*/
|
|
67
|
+
dirFallback?: ParsedFilter
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
@@ -187,6 +194,15 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
187
194
|
}
|
|
188
195
|
}
|
|
189
196
|
|
|
197
|
+
// A package name holds a `/` only after its `@scope`, so `apps/*` is a
|
|
198
|
+
// directory for every project named after its package.json; Nx matches
|
|
199
|
+
// a `--projects` entry by name, then by root (find-matching-projects,
|
|
200
|
+
// 23.2.1), and `-p 'apps/*'` matched nothing here.
|
|
201
|
+
const dirFallback =
|
|
202
|
+
!isPath && s.includes('/') && !s.startsWith('@')
|
|
203
|
+
? parseFilter(`./${s}`, workspaceRoot)
|
|
204
|
+
: undefined
|
|
205
|
+
|
|
190
206
|
return {
|
|
191
207
|
raw,
|
|
192
208
|
negate,
|
|
@@ -196,6 +212,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
196
212
|
onlyDependents,
|
|
197
213
|
isPath,
|
|
198
214
|
matcher,
|
|
215
|
+
...(dirFallback !== undefined ? { dirFallback } : {}),
|
|
199
216
|
...(pathGlob !== undefined ? { pathGlob, pathRoot: workspaceRoot } : {}),
|
|
200
217
|
...(pathGlobBase !== undefined ? { pathGlobBase } : {}),
|
|
201
218
|
...(gitSince !== undefined ? { gitSince } : {}),
|
|
@@ -278,6 +295,8 @@ function matchSelector(
|
|
|
278
295
|
for (const p of projects) {
|
|
279
296
|
if (re.test(p.name)) out.push(p.name)
|
|
280
297
|
}
|
|
298
|
+
if (out.length === 0 && filter.dirFallback !== undefined)
|
|
299
|
+
return matchSelector(filter.dirFallback, projects, tags)
|
|
281
300
|
if (out.length > 0 || filter.matcher.includes('/')) return out
|
|
282
301
|
// pnpm's rule: the scope may be left out (`--filter core` is
|
|
283
302
|
// `@babel/core`), an exact name only when one package carries it. Nx
|
|
@@ -429,7 +429,8 @@ function pmRunsMembers(script: string): boolean {
|
|
|
429
429
|
return false
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
-
|
|
432
|
+
// A bare target may escape a space or paren (`cd packages/my\ app`).
|
|
433
|
+
const CD = /(?:^|[\s;&|(])cd\s+("[^"]*"|'[^']*'|(?:\\.|[^\s;&|()\\])+)/g
|
|
433
434
|
|
|
434
435
|
/**
|
|
435
436
|
* A `cd` into a member's directory, or one holding members, runs that
|
|
@@ -439,7 +440,8 @@ const CD = /(?:^|[\s;&|(])cd\s+("[^"]*"|'[^']*'|[^\s;&|()]+)/g
|
|
|
439
440
|
*/
|
|
440
441
|
function cdsToMembers(script: string, rootDir: string, memberDirs: readonly string[]): boolean {
|
|
441
442
|
for (const m of script.matchAll(CD)) {
|
|
442
|
-
const
|
|
443
|
+
const quoted = /^(["'])(.*)\1$/.exec(m[1]!)
|
|
444
|
+
const target = quoted ? quoted[2]! : m[1]!.replace(/\\(.)/g, '$1')
|
|
443
445
|
if (/[$`~*?]/.test(target) || target === '-') return true
|
|
444
446
|
const dir = path.resolve(rootDir, target)
|
|
445
447
|
const inside = (a: string, b: string): boolean => a === b || a.startsWith(b + path.sep)
|
|
@@ -541,7 +543,7 @@ function siblingRun(script: string, dir: string, others: readonly string[]): str
|
|
|
541
543
|
function lernaPackages(dir: string): string[] | undefined {
|
|
542
544
|
let json: unknown
|
|
543
545
|
try {
|
|
544
|
-
json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8'))
|
|
546
|
+
json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8').replace(/^\uFEFF/, ''))
|
|
545
547
|
} catch {
|
|
546
548
|
return undefined
|
|
547
549
|
}
|
|
@@ -406,7 +406,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
406
406
|
: []
|
|
407
407
|
if (empty && unreached.length > 0) {
|
|
408
408
|
report.push(
|
|
409
|
-
`${verb}: ${unreachedHint(unreached)}`,
|
|
409
|
+
`${verb}: ${unreachedHint(unreached, root)}`,
|
|
410
410
|
hasWorkspaceFile
|
|
411
411
|
? `${workspaceName} already exists.`
|
|
412
412
|
: dry
|
|
@@ -73,7 +73,7 @@ export function buildPackageGraph(
|
|
|
73
73
|
const dev = bucket(p.packageJson.devDependencies)
|
|
74
74
|
const add = (key: string, spec: unknown, installed: boolean, into: Set<string>): void => {
|
|
75
75
|
if (typeof spec !== 'string') return
|
|
76
|
-
const target = linked(key, spec.trim(), p.dir, installed)
|
|
76
|
+
const target = linked(key, fromCatalog(p, key, spec.trim()), p.dir, installed)
|
|
77
77
|
if (target !== undefined && target !== p.name) into.add(target)
|
|
78
78
|
}
|
|
79
79
|
// Which entry is installed when a key sits in more than one field,
|
|
@@ -300,6 +300,19 @@ export function buildPackageGraph(
|
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
/**
|
|
304
|
+
* The spec a `catalog:` / `catalog:<name>` entry stands for in the
|
|
305
|
+
* workspace's catalog, so it is an edge by the rule its range or protocol
|
|
306
|
+
* meets (zenstack's `nuxt: "catalog:"` is `4.3.1`, which the workspace's
|
|
307
|
+
* versionless `nuxt` sample does not satisfy). A catalog the graph was not
|
|
308
|
+
* given or that lacks the key leaves the spec, which keeps its edge.
|
|
309
|
+
*/
|
|
310
|
+
function fromCatalog(p: ProjectMeta, key: string, spec: string): string {
|
|
311
|
+
if (p.catalogs === undefined || !spec.startsWith('catalog:')) return spec
|
|
312
|
+
const resolved = p.catalogs.get(spec.slice('catalog:'.length).trim() || 'default')?.[key]
|
|
313
|
+
return typeof resolved === 'string' && !resolved.startsWith('catalog:') ? resolved.trim() : spec
|
|
314
|
+
}
|
|
315
|
+
|
|
303
316
|
function bucket(field: unknown): Readonly<Record<string, unknown>> {
|
|
304
317
|
return typeof field === 'object' && field !== null ? (field as Record<string, unknown>) : {}
|
|
305
318
|
}
|
|
@@ -377,8 +390,7 @@ function linkedTarget(
|
|
|
377
390
|
if (!MAY_POINT_ELSEWHERE.test(spec)) {
|
|
378
391
|
const local = byName.get(key)
|
|
379
392
|
if (local === undefined) return undefined
|
|
380
|
-
// A catalog entry's
|
|
381
|
-
// (`pnpm-workspace.yaml`, bun's root `catalog`): it keeps the edge.
|
|
393
|
+
// A catalog entry its workspace's catalog did not resolve keeps the edge.
|
|
382
394
|
if (!installed || spec.startsWith('catalog:')) return key
|
|
383
395
|
return satisfies(local, spec) ? key : undefined
|
|
384
396
|
}
|
|
@@ -127,10 +127,12 @@ function servableSource(bytes: Uint8Array, loader: 'ts' | 'js'): string | null {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
// What makes Bun run a file as CommonJS is one of these names at the top
|
|
130
|
-
// level (an escaped `\u006dodule` too, so any backslash counts)
|
|
130
|
+
// level (an escaped `\u006dodule` too, so any backslash counts), or
|
|
131
|
+
// TypeScript's `export =`, which spells none of them. Source
|
|
131
132
|
// with none of them runs as a module whichever path loads it, so it skips
|
|
132
133
|
// the parse: 16–20 µs a config, 1,000 cold configs (2026-10-03).
|
|
133
|
-
const COMMONJS_HINT =
|
|
134
|
+
const COMMONJS_HINT =
|
|
135
|
+
/\b(?:module|exports|require|this|__dirname|__filename)\b|\\|\bexport\s*=(?!=)/
|
|
134
136
|
|
|
135
137
|
/** vx's module-cache query, which no user wrote: stripped from anything shown to them. */
|
|
136
138
|
const BUST_QUERY = /\?vx-(?:bust|held)=[^'"\s]*/g
|
|
@@ -479,6 +481,7 @@ export async function loadProjectConfigs(
|
|
|
479
481
|
let env: Readonly<Record<string, string | undefined>> = {}
|
|
480
482
|
let cwd = ''
|
|
481
483
|
let umask = -1
|
|
484
|
+
let unredirect: (() => void) | undefined
|
|
482
485
|
// More than one evaluation in flight: a change seen after one load may
|
|
483
486
|
// be another's.
|
|
484
487
|
let overlapping = false
|
|
@@ -588,6 +591,7 @@ export async function loadProjectConfigs(
|
|
|
588
591
|
else misses.push(i)
|
|
589
592
|
}
|
|
590
593
|
if (misses.length > 0) {
|
|
594
|
+
unredirect = stdoutToStderr()
|
|
591
595
|
builtins = builtinSnapshot()
|
|
592
596
|
env = { ...process.env }
|
|
593
597
|
cwd = process.cwd()
|
|
@@ -651,6 +655,7 @@ export async function loadProjectConfigs(
|
|
|
651
655
|
)
|
|
652
656
|
return results.map((r) => (r as Loaded).config)
|
|
653
657
|
} finally {
|
|
658
|
+
unredirect?.()
|
|
654
659
|
endRound()
|
|
655
660
|
if (store !== undefined && !tainted) {
|
|
656
661
|
if (evals.length > 0) {
|
|
@@ -665,6 +670,72 @@ export async function loadProjectConfigs(
|
|
|
665
670
|
}
|
|
666
671
|
}
|
|
667
672
|
|
|
673
|
+
/**
|
|
674
|
+
* Every route to fd 1 sent to stderr while configs evaluate in this
|
|
675
|
+
* process; returns the release. A verb's stdout is its output (`vx show
|
|
676
|
+
* --format json`), and a config's `console.log` came out ahead of the
|
|
677
|
+
* JSON. The worker does the same for a repeat load (D-64), `vx mcp` for
|
|
678
|
+
* its whole serve. Taken before a round's built-in snapshot and released
|
|
679
|
+
* after its check, so the guard sees no change of vx's own.
|
|
680
|
+
*
|
|
681
|
+
* Counted, because rounds overlap (`--affected`'s per-file sweep loads each
|
|
682
|
+
* config in a round of its own): a second install over the first moved
|
|
683
|
+
* `Bun.write` and `console` under the first round's snapshot, which refused
|
|
684
|
+
* the loads, and the undos ran out of order and left a redirect in place
|
|
685
|
+
* for good. The first taker installs; the last release restores.
|
|
686
|
+
*/
|
|
687
|
+
let redirectHolders = 0
|
|
688
|
+
let undoRedirect = (): void => {}
|
|
689
|
+
/**
|
|
690
|
+
* Bun's own `Console`, taken at load: the console a round finds may be a
|
|
691
|
+
* constructed one (`vx mcp` serves under its own), which has no `Console`.
|
|
692
|
+
* The global's, not `node:console`'s: the playground bundles this module
|
|
693
|
+
* for the browser, where that specifier is a stub.
|
|
694
|
+
*/
|
|
695
|
+
const BunConsole = (globalThis.console as { Console?: typeof console.Console }).Console
|
|
696
|
+
|
|
697
|
+
function stdoutToStderr(): () => void {
|
|
698
|
+
if (redirectHolders++ === 0) undoRedirect = installRedirect()
|
|
699
|
+
return () => {
|
|
700
|
+
if (--redirectHolders === 0) undoRedirect()
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function installRedirect(): () => void {
|
|
705
|
+
const out = process.stdout
|
|
706
|
+
const ownWrite = Object.getOwnPropertyDescriptor(out, 'write')
|
|
707
|
+
const ownConsole = globalThis.console
|
|
708
|
+
const bun = Bun as { write: typeof Bun.write }
|
|
709
|
+
const ownBunWrite = Bun.write
|
|
710
|
+
const ownWriter = Object.getOwnPropertyDescriptor(Bun.stdout, 'writer')
|
|
711
|
+
out.write = ((...args: Parameters<typeof process.stderr.write>) =>
|
|
712
|
+
process.stderr.write(...args)) as typeof out.write
|
|
713
|
+
bun.write = ((dest: unknown, ...rest: unknown[]) =>
|
|
714
|
+
(ownBunWrite as (...a: unknown[]) => Promise<number>)(
|
|
715
|
+
dest === Bun.stdout ? Bun.stderr : dest,
|
|
716
|
+
...rest,
|
|
717
|
+
)) as typeof Bun.write
|
|
718
|
+
;(Bun.stdout as { writer: typeof Bun.stdout.writer }).writer = ((
|
|
719
|
+
...args: Parameters<typeof Bun.stderr.writer>
|
|
720
|
+
) => Bun.stderr.writer(...args)) as typeof Bun.stdout.writer
|
|
721
|
+
// Bun's console adds `write`, which a constructed Console lacks.
|
|
722
|
+
globalThis.console = Object.assign(new BunConsole!(process.stderr, process.stderr), {
|
|
723
|
+
write: (...data: string[]) => {
|
|
724
|
+
const text = data.join('')
|
|
725
|
+
process.stderr.write(text)
|
|
726
|
+
return text.length
|
|
727
|
+
},
|
|
728
|
+
})
|
|
729
|
+
return () => {
|
|
730
|
+
if (ownWrite === undefined) delete (out as { write?: unknown }).write
|
|
731
|
+
else Object.defineProperty(out, 'write', ownWrite)
|
|
732
|
+
bun.write = ownBunWrite
|
|
733
|
+
if (ownWriter === undefined) delete (Bun.stdout as { writer?: unknown }).writer
|
|
734
|
+
else Object.defineProperty(Bun.stdout, 'writer', ownWriter)
|
|
735
|
+
globalThis.console = ownConsole
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
668
739
|
/**
|
|
669
740
|
* The built-in prototypes a config's object is read through. A config that
|
|
670
741
|
* set `Object.prototype.exec` gave every other project's task that
|
|
@@ -751,6 +822,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
|
|
|
751
822
|
'sleep',
|
|
752
823
|
'spawn',
|
|
753
824
|
'spawnSync',
|
|
825
|
+
'stderr',
|
|
754
826
|
'stdout',
|
|
755
827
|
'stringWidth',
|
|
756
828
|
'stripANSI',
|
|
@@ -1039,6 +1111,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
|
|
|
1039
1111
|
// left out: it loads first in every run, filtered or not, so none
|
|
1040
1112
|
// depends on what else loaded (D-122's case), and plugins' tests and
|
|
1041
1113
|
// tools hand state through them.
|
|
1114
|
+
const unredirect = stdoutToStderr()
|
|
1042
1115
|
const builtins = builtinSnapshot('globalThis', true)
|
|
1043
1116
|
const env = { ...process.env }
|
|
1044
1117
|
const cwd = process.cwd()
|
|
@@ -1053,6 +1126,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
|
|
|
1053
1126
|
...restoreCwd(cwd),
|
|
1054
1127
|
...restoreUmask(umask),
|
|
1055
1128
|
]
|
|
1129
|
+
unredirect()
|
|
1056
1130
|
// eslint-disable-next-line no-unsafe-finally -- the refusal outranks the load's own error
|
|
1057
1131
|
if (changed.length > 0) throw builtinsChanged(changed, configPath)
|
|
1058
1132
|
}
|