@vzn/vx 0.0.50 → 0.0.52

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.
Files changed (57) hide show
  1. package/README.md +38 -31
  2. package/package.json +5 -5
  3. package/src/bin.ts +1 -1
  4. package/src/cache/archive.ts +18 -6
  5. package/src/cache/cache.ts +150 -67
  6. package/src/cache/chained-cache.ts +9 -2
  7. package/src/cache/file-hashes.ts +32 -3
  8. package/src/cache/git-inputs.ts +96 -11
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/layer.ts +28 -3
  11. package/src/cache/layered-cache.ts +7 -2
  12. package/src/cache/output-index.ts +16 -7
  13. package/src/cache/run-history.ts +5 -1
  14. package/src/cache/zstd.ts +40 -8
  15. package/src/cli/cache.ts +35 -9
  16. package/src/cli/completions.ts +10 -7
  17. package/src/cli/help.ts +48 -4
  18. package/src/cli/index.ts +5 -3
  19. package/src/cli/info.ts +1 -1
  20. package/src/cli/plugin-commands.ts +10 -2
  21. package/src/cli/run.ts +4 -0
  22. package/src/cli/select.ts +32 -2
  23. package/src/cli/upgrade.ts +110 -22
  24. package/src/cli/watch.ts +38 -20
  25. package/src/config.ts +18 -5
  26. package/src/exec/executor.ts +3 -2
  27. package/src/exec/index.ts +1 -1
  28. package/src/exec/runner.ts +102 -24
  29. package/src/exec/sandbox-binds.ts +1 -1
  30. package/src/exec/sandbox-runtime.ts +149 -40
  31. package/src/exec/sandbox-violations.ts +42 -10
  32. package/src/graph/scheduler.ts +11 -5
  33. package/src/graph/task-graph.ts +96 -12
  34. package/src/index.ts +1 -8
  35. package/src/orchestrator/admission.ts +72 -17
  36. package/src/orchestrator/execute-task.ts +39 -25
  37. package/src/orchestrator/hit-restore.ts +7 -1
  38. package/src/orchestrator/miss-save.ts +29 -1
  39. package/src/orchestrator/options.ts +8 -1
  40. package/src/orchestrator/placement.ts +23 -3
  41. package/src/orchestrator/plugin-host.ts +1 -1
  42. package/src/orchestrator/plugin.ts +9 -1
  43. package/src/orchestrator/prepare.ts +28 -10
  44. package/src/orchestrator/run.ts +75 -12
  45. package/src/orchestrator/sandbox-request.ts +19 -10
  46. package/src/orchestrator/summary.ts +26 -15
  47. package/src/orchestrator/task-hash.ts +7 -2
  48. package/src/orchestrator/telemetry.ts +14 -4
  49. package/src/util/paths.ts +16 -9
  50. package/src/workspace/affected.ts +201 -38
  51. package/src/workspace/config-cache.ts +0 -2
  52. package/src/workspace/config-eval.ts +4 -1
  53. package/src/workspace/config-schema.ts +70 -10
  54. package/src/workspace/fingerprint.ts +6 -0
  55. package/src/workspace/migration.ts +1 -1
  56. package/src/workspace/project-loader.ts +53 -1
  57. package/src/workspace/workspace.ts +54 -14
@@ -282,9 +282,16 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
282
282
  const sandbox = (exec as { sandbox?: unknown }).sandbox
283
283
  if (sandbox !== undefined) validateSandbox(sandbox, `${where}.exec`)
284
284
  const command = (exec as { command?: unknown }).command
285
- if (typeof command !== 'string' || command.length === 0) {
285
+ // Whitespace alone ran as a shell no-op, green and cached (item 1099).
286
+ if (typeof command !== 'string' || command.trim().length === 0) {
286
287
  throw new UserError(`${where}.exec.command must be a non-empty string`)
287
288
  }
289
+ // No argv can carry a NUL: the spawn refused it and the task failed
290
+ // as exit 127, "not on this task's PATH", with the NUL printed as a
291
+ // space (D-4), as an env name with one did before item 999.
292
+ if (command.includes('\0')) {
293
+ throw new UserError(`${where}.exec.command holds a NUL, which no command line can carry`)
294
+ }
288
295
  const timeout = (exec as { timeout?: unknown }).timeout
289
296
  if (timeout !== undefined) {
290
297
  if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0) {
@@ -329,6 +336,16 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
329
336
  `${where}.exec.env.passThrough must be an array of env var names (non-empty, no '=' or NUL)`,
330
337
  )
331
338
  }
339
+ // `VITE_*` passed nothing through and loaded clean (Turbo's
340
+ // passThroughEnv expands it), where `cache.inputs.env` refused the
341
+ // same text below: one refusal for the class (item 1090).
342
+ const wild = (passThrough as string[]).find((n) => BUN_GLOB_WILDCARDS.test(n))
343
+ if (wild !== undefined) {
344
+ throw new UserError(
345
+ `${where}.exec.env.passThrough: wildcards in env names are not supported ` +
346
+ `(got "${wild}") — list explicit env var names instead`,
347
+ )
348
+ }
332
349
  }
333
350
  const define = (env as { define?: unknown }).define
334
351
  if (define !== undefined) {
@@ -582,9 +599,12 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
582
599
  for (const field of ['runtime', 'workspaceRuntime'] as const) {
583
600
  const list = (inputs as Record<string, unknown>)[field]
584
601
  if (list !== undefined) {
585
- if (!Array.isArray(list) || list.some((s) => typeof s !== 'string' || s.length === 0)) {
602
+ if (
603
+ !Array.isArray(list) ||
604
+ list.some((s) => typeof s !== 'string' || s.length === 0 || s.includes('\0'))
605
+ ) {
586
606
  throw new UserError(
587
- `${where}.cache.inputs.${field} must be an array of non-empty shell command strings`,
607
+ `${where}.cache.inputs.${field} must be an array of non-empty shell command strings with no NUL`,
588
608
  )
589
609
  }
590
610
  }
@@ -633,6 +653,40 @@ const CACHE_INPUT_FIELDS = new Set([
633
653
  ])
634
654
  const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles'])
635
655
 
656
+ /** A field a release took out of the schema, and what took its place. */
657
+ interface Removal {
658
+ /** The vx version whose schema first refused it. */
659
+ removedIn: string
660
+ /** What to write instead, as a sentence fragment after "use". */
661
+ use: string
662
+ }
663
+
664
+ /**
665
+ * Fields the schema once accepted, by the level that held them (keyed by
666
+ * that level's field set). A removed field is refused naming its
667
+ * replacement and the version that removed it, never as an unknown field a
668
+ * reader must research (docs/design/versioning-1.0.md § Deprecation). An
669
+ * entry stays for good: a config written against an old release meets it
670
+ * whenever it upgrades.
671
+ */
672
+ export const REMOVED_FIELDS: ReadonlyMap<
673
+ ReadonlySet<string>,
674
+ Readonly<Record<string, Removal>>
675
+ > = new Map([
676
+ [
677
+ EXEC_FIELDS,
678
+ {
679
+ // Reservations left core with the `admit` seam (item 157).
680
+ resources: {
681
+ removedIn: '0.0.19',
682
+ use:
683
+ "`@vzn/vx-schedule-history`, which learns each task's reservation from its run " +
684
+ "history (declare one by hand with its `reservations: { 'pkg#task': { cpus, memory } }`)",
685
+ },
686
+ },
687
+ ],
688
+ ])
689
+
636
690
  function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: string): void {
637
691
  // `typeof [] === 'object'`, so an array reaches here and its indices read
638
692
  // as fields: `outputs: ['dist/**']` (Turbo's spelling) was refused as
@@ -645,6 +699,12 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
645
699
  }
646
700
  for (const key of Object.keys(value)) {
647
701
  if (!allowed.has(key)) {
702
+ const removal = REMOVED_FIELDS.get(allowed)?.[key]
703
+ if (removal !== undefined) {
704
+ throw new UserError(
705
+ `${where} has field "${key}", which vx ${removal.removedIn} removed — use ${removal.use}`,
706
+ )
707
+ }
648
708
  // The nearest accepted spelling first: the list says what the level
649
709
  // takes, the hint says which one was meant.
650
710
  const near = nearest(key, allowed)
@@ -934,6 +994,7 @@ const GRANT_FIELDS = new Set<string>([
934
994
  'unixSockets',
935
995
  ])
936
996
  const DENY_FIELDS = new Set(['network'])
997
+ const IGNORE_FIELDS = new Set(['read', 'write', 'systemInfo', 'network'])
937
998
 
938
999
  function assertStringArray(v: unknown, where: string): void {
939
1000
  if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
@@ -1021,17 +1082,16 @@ function validateSandbox(sandbox: unknown, where: string): void {
1021
1082
  if (typeof ignore !== 'object' || ignore === null || Array.isArray(ignore)) {
1022
1083
  throw new UserError(`${where}.sandbox.ignore must be an object`)
1023
1084
  }
1024
- assertKnownFields(ignore, GRANT_FIELDS, `${where}.sandbox.ignore`)
1085
+ // Only these reach the report's filter (`matchesIgnore`): a denial is
1086
+ // classed read, write, systemInfo or network, and nothing else. Every
1087
+ // grant name loaded here once, so `ignore.machLookup` silenced nothing
1088
+ // and said so nowhere (D-4).
1089
+ assertKnownFields(ignore, IGNORE_FIELDS, `${where}.sandbox.ignore`)
1025
1090
  const g = ignore as Record<string, unknown>
1026
1091
  // Patterns, not prefixes — `ignore` is vx's own filter over lines it
1027
1092
  // already holds, so a glob costs nothing and works on every platform.
1028
- for (const f of [...GRANT_PATH_FIELDS, ...GRANT_NAME_FIELDS, 'network', 'unixSockets']) {
1093
+ for (const f of IGNORE_FIELDS) {
1029
1094
  if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.ignore.${f}`)
1030
1095
  }
1031
- for (const f of GRANT_BOOL_FIELDS) {
1032
- if (g[f] !== undefined) {
1033
- throw new UserError(`${where}.sandbox.ignore.${f} is a flag, not something to ignore`)
1034
- }
1035
- }
1036
1096
  }
1037
1097
  }
@@ -30,6 +30,12 @@ export const WORKSPACE_FINGERPRINT_FILES = [
30
30
  // (turborepo#12635). pnpm's catalogs live in pnpm-workspace.yaml, and
31
31
  // its lockfile records each importer's resolved version besides.
32
32
  '.yarnrc.yml',
33
+ // Install settings no lockfile records: bun's `linker = "isolated"` and
34
+ // pnpm's `node-linker` / hoisting lay `node_modules` out anew under a
35
+ // byte-identical lockfile, and a build that imported an undeclared
36
+ // hoisted package kept its green hit where it now fails (D-7).
37
+ '.npmrc',
38
+ 'bunfig.toml',
33
39
  ]
34
40
 
35
41
  // DELIBERATELY ABSENT: `vx.workspace.{ts,mts,js,mjs}`. Everything it can
@@ -101,7 +101,7 @@ export interface MigrationPlan {
101
101
  projects: GeneratedProject[]
102
102
  /** extra root-relative files (e.g. the preset) */
103
103
  extraFiles: { relPath: string; contents: string }[]
104
- /** trailing report lines (e.g. implicit Nx deps) */
104
+ /** trailing report lines (e.g. what a turbo.json holds that vx has no place for) */
105
105
  notes: string[]
106
106
  }
107
107
 
@@ -1,10 +1,16 @@
1
+ import { realpathSync, statSync } from 'node:fs'
1
2
  import path from 'node:path'
2
3
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
3
4
  import { UserError, xxh3hex } from '../util/index.js'
4
5
  import { validateProjectConfig, validateWorkspace } from './config-schema.js'
5
6
  import { beginEvalRound, evaluateConfigFresh } from './config-eval.js'
6
7
  import { hasEsmExport, unprovidedBareImports } from './config-imports.js'
7
- import { configEvalKey, configEvalKeyFromClosure, type ConfigEvalStore } from './config-cache.js'
8
+ import {
9
+ configEvalKey,
10
+ configEvalKeyFromClosure,
11
+ configImports,
12
+ type ConfigEvalStore,
13
+ } from './config-cache.js'
8
14
  import { readOnce } from './load-reads.js'
9
15
 
10
16
  // The validator lives in config-schema.ts; re-exported so a reader that
@@ -420,6 +426,41 @@ export async function loadProjectConfig(
420
426
  return config!
421
427
  }
422
428
 
429
+ /** When this process first began loading each workspace config, in ms. */
430
+ const workspaceLoadedAt = new Map<string, number>()
431
+
432
+ /**
433
+ * A repeat load in one process (`vx mcp`'s every call, `vx watch`'s every
434
+ * cycle) busts the workspace config's own URL, but Bun answers what it
435
+ * imports from the module registry: an edited local plugin kept its first
436
+ * version and `listTasks` served its old tasks with no word (item 1046).
437
+ * Bun cannot evaluate an imported module again, so the load is refused,
438
+ * naming the file, as `vx watch` already says when it sees the edit. Only
439
+ * a repeat load pays for the walk; a CLI verb loads once.
440
+ */
441
+ async function refuseStaleWorkspaceImports(configPath: string, since: number): Promise<void> {
442
+ for (const file of await configImports(configPath)) {
443
+ let mtime: number
444
+ try {
445
+ mtime = statSync(file).mtimeMs
446
+ } catch {
447
+ continue
448
+ }
449
+ // `since` is whole milliseconds and an mtime is not: a file written in
450
+ // the millisecond the first load began (1000.4 against 1000) read as
451
+ // changed, and the control reload was refused one run in ten. An edit
452
+ // inside that millisecond is one the load may have read anyway.
453
+ if (Math.floor(mtime) > since) {
454
+ // The walk names real paths: a root reached through a symlink (macOS's
455
+ // temp dir) named `../target/helper.mjs` from the link.
456
+ const from = path.dirname(realpathSync(configPath))
457
+ throw new UserError(
458
+ `${path.basename(configPath)} imports ${path.relative(from, file)}, which changed after this process loaded it; a running process cannot evaluate an imported module again — restart it to apply the edit`,
459
+ )
460
+ }
461
+ }
462
+ }
463
+
423
464
  /**
424
465
  * Find and load `vx.workspace.{ts,mts,js,mjs}` from the workspace
425
466
  * root. Returns `null` if no such file exists (the common case;
@@ -430,8 +471,19 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
430
471
  for (const configPath of WORKSPACE_CONFIG_FILENAMES.map((f) => path.join(root, f))) {
431
472
  const bytes = await readOnce(undefined, configPath)
432
473
  if (bytes === null) continue
474
+ const since = workspaceLoadedAt.get(configPath)
475
+ if (since !== undefined) await refuseStaleWorkspaceImports(configPath, since)
476
+ const startedAt = Date.now()
433
477
  const mod = (await loadDefaultExport(configPath, 'Workspace', bytes)) as WorkspaceConfig
478
+ // Checked again once awaited, as a project config is: a Promise default
479
+ // passed the first check, and `Promise.resolve(null)` crashed the
480
+ // validator with a stack while `Promise.resolve(42)` loaded as no
481
+ // config at all (D-6).
482
+ assertDefaultObject(mod, 'Workspace', configPath)
434
483
  validateWorkspace(mod, configPath)
484
+ // Set by a load that succeeded: a failed one may have left nothing in
485
+ // the registry, and its fix must not be refused.
486
+ if (since === undefined) workspaceLoadedAt.set(configPath, startedAt)
435
487
  return mod
436
488
  }
437
489
  return null
@@ -416,25 +416,65 @@ async function memberDirs(root: string, pattern: string): Promise<string[]> {
416
416
  }
417
417
  return dirs
418
418
  }
419
- const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
420
- const glob = new Bun.Glob(globPattern)
421
419
  const dirs: string[] = []
422
- // A linked member is a member, as the readdir path above has it:
423
- // `packages/*` found `packages/b -> ../ext/b` while `packages/{a,b}` and
424
- // `pack*/*` did not (item 987). Followed only where the depth is bounded:
425
- // under `**` the scan would walk every pnpm `node_modules` link.
426
- const followSymlinks = !normalized.includes('**')
427
- for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false, followSymlinks })) {
428
- // Skip nested node_modules — workspace package globs shouldn't
429
- // ever reach into them, but a pathological pattern like `**`
430
- // would. Avoid splitting the path on the hot loop.
431
- if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
432
- if (rel.startsWith(`node_modules${path.sep}`)) continue
433
- dirs.push(path.dirname(path.resolve(root, rel)))
420
+ // `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
421
+ // slash, though its `match` reads one: `packages/{a,nested/b}` listed
422
+ // neither package, and both ran under no verb while the root still
423
+ // claimed them (D-2). Such a pattern is scanned as its expansions.
424
+ for (const expanded of slashBraceExpansions(normalized)) {
425
+ const globPattern = expanded === '' ? 'package.json' : `${expanded}/package.json`
426
+ const glob = new Bun.Glob(globPattern)
427
+ // A linked member is a member, as the readdir path above has it:
428
+ // `packages/*` found `packages/b -> ../ext/b` while `packages/{a,b}` and
429
+ // `pack*/*` did not (item 987). Followed only where the depth is bounded:
430
+ // under `**` the scan would walk every pnpm `node_modules` link.
431
+ const followSymlinks = !expanded.includes('**')
432
+ for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false, followSymlinks })) {
433
+ // Skip nested node_modules — workspace package globs shouldn't
434
+ // ever reach into them, but a pathological pattern like `**`
435
+ // would. Avoid splitting the path on the hot loop.
436
+ if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
437
+ if (rel.startsWith(`node_modules${path.sep}`)) continue
438
+ dirs.push(path.dirname(path.resolve(root, rel)))
439
+ }
434
440
  }
435
441
  return dirs
436
442
  }
437
443
 
444
+ /**
445
+ * `pattern` with its first brace group expanded, recursively, while that
446
+ * group holds a `/`; any other pattern as itself, since `Bun.Glob`
447
+ * expands a slash-free brace on its own. An unbalanced or escaped brace
448
+ * is left to the glob.
449
+ */
450
+ function slashBraceExpansions(pattern: string): string[] {
451
+ if (pattern.includes('\\')) return [pattern]
452
+ for (let open = pattern.indexOf('{'); open !== -1; open = pattern.indexOf('{', open + 1)) {
453
+ let depth = 0
454
+ const cuts: number[] = []
455
+ let close = -1
456
+ for (let i = open; i < pattern.length && close === -1; i++) {
457
+ const c = pattern[i]
458
+ if (c === '{') depth++
459
+ else if (c === '}' && --depth === 0) close = i
460
+ else if (c === ',' && depth === 1) cuts.push(i)
461
+ }
462
+ if (close === -1) return [pattern]
463
+ const body = pattern.slice(open + 1, close)
464
+ if (cuts.length === 0 || !body.includes('/')) continue
465
+ const head = pattern.slice(0, open)
466
+ const tail = pattern.slice(close + 1)
467
+ const bounds = [open, ...cuts, close]
468
+ const out: string[] = []
469
+ for (let k = 0; k + 1 < bounds.length; k++) {
470
+ const alt = pattern.slice(bounds[k]! + 1, bounds[k + 1])
471
+ out.push(...slashBraceExpansions(head + alt + tail))
472
+ }
473
+ return [...new Set(out)]
474
+ }
475
+ return [pattern]
476
+ }
477
+
438
478
  /**
439
479
  * The project's config file, by `PROJECT_CONFIG_FILENAMES` precedence, or null.
440
480
  *