@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -23,6 +23,7 @@ import {
23
23
  UserError,
24
24
  BUN_GLOB_WILDCARDS,
25
25
  EXTGLOB,
26
+ braceAlternatives,
26
27
  braceRange,
27
28
  BRACE_RANGE_LIMIT,
28
29
  anyTaskGlob,
@@ -179,15 +180,17 @@ function validateSettingsAt(value: unknown, at: string, configPath: string): voi
179
180
  }
180
181
  return
181
182
  }
182
- if (at === 'run.rules.dependencies') validateDependencyRules(value, where)
183
+ if (at === 'run.rules.dependencies') validateDependencyRules(value, where, configPath)
183
184
  else validateSettingValue(value, at, where)
184
185
  }
185
186
 
186
187
  const RULE_FIELDS = new Set(['from', 'allow', 'deny'])
187
188
 
188
- function validateDependencyRules(value: unknown, where: string): void {
189
+ function validateDependencyRules(value: unknown, where: string, configPath: string): void {
189
190
  const shape = `an array of { from, allow?, deny? }, each a selector like 'tag:app' or '@acme/*'`
190
191
  if (!Array.isArray(value)) throw new UserError(`${where} must be ${shape}`)
192
+ // The whole path inside one code span, as the frame reads a setting.
193
+ const at = (i: number, rest = '') => `${configPath}: \`run.rules.dependencies[${i}]${rest}\``
191
194
  const selector = (v: unknown) => typeof v === 'string' && v.trim() !== '' && v !== 'tag:'
192
195
  const matchable = (v: string, field: string) => {
193
196
  const why = ruleSelectorProblem(v)
@@ -198,24 +201,24 @@ function validateDependencyRules(value: unknown, where: string): void {
198
201
  )
199
202
  }
200
203
  for (const [i, rule] of (value as unknown[]).entries()) {
201
- const at = `${where}[${i}]`
202
204
  if (typeof rule !== 'object' || rule === null || Array.isArray(rule))
203
- throw new UserError(`${at} must be { from, allow?, deny? }`)
204
- assertKnownFields(rule, RULE_FIELDS, at)
205
+ throw new UserError(`${at(i)} must be { from, allow?, deny? }`)
206
+ assertKnownFields(rule, RULE_FIELDS, at(i))
205
207
  const { from, allow, deny } = rule as Record<string, unknown>
206
- if (!selector(from)) throw new UserError(`${at}.from must be a selector like 'tag:app'`)
207
- matchable(from as string, `${at}.from`)
208
+ if (!selector(from)) throw new UserError(`${at(i, '.from')} must be a selector like 'tag:app'`)
209
+ matchable(from as string, at(i, '.from'))
208
210
  for (const [k, list] of [
209
211
  ['allow', allow],
210
212
  ['deny', deny],
211
213
  ] as const) {
214
+ const field = at(i, `.${k}`)
212
215
  if (list !== undefined && (!Array.isArray(list) || !list.every(selector)))
213
- throw new UserError(`${at}.${k} must be an array of selectors like 'tag:lib'`)
214
- for (const [j, v] of ((list ?? []) as string[]).entries()) matchable(v, `${at}.${k}[${j}]`)
216
+ throw new UserError(`${field} must be an array of selectors like 'tag:lib'`)
217
+ for (const [j, v] of ((list ?? []) as string[]).entries()) matchable(v, at(i, `.${k}[${j}]`))
215
218
  }
216
219
  // A rule that says nothing reads as a constraint and checks nothing.
217
220
  if (allow === undefined && deny === undefined)
218
- throw new UserError(`${at} names neither \`allow\` nor \`deny\``)
221
+ throw new UserError(`${at(i)} names neither \`allow\` nor \`deny\``)
219
222
  }
220
223
  }
221
224
 
@@ -514,12 +517,14 @@ export function validateProjectConfig(
514
517
  config: ProjectConfig,
515
518
  configPath: string,
516
519
  ownConfig: string | null = configPath,
520
+ /** The caller refused non-JSON values already and passes a JSON copy (project-loader.ts). */
521
+ jsonCopy = false,
517
522
  ): void {
518
523
  // Before the schema, on the object as it stands: the key folds its JSON,
519
524
  // so a value JSON cannot carry is refused here and in the config worker
520
525
  // alike (json-data.ts). A plugin's `project` edit and a lock entry cross
521
526
  // this line too.
522
- const [nonJson] = nonJsonPaths(config)
527
+ const [nonJson] = jsonCopy ? [] : nonJsonPaths(config)
523
528
  if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
524
529
  // The top level too: `task:` (singular) loaded as a project with no
525
530
  // tasks and every request against it said "no projects declare".
@@ -597,7 +602,13 @@ export function validateProjectConfig(
597
602
  }
598
603
  if (exec !== undefined) {
599
604
  if (typeof exec !== 'object' || exec === null) {
600
- throw new UserError(`${where}.exec must be an object with a \`command\` string`)
605
+ // A command written as the exec itself, as the task-as-a-string
606
+ // refusal above spells its fix.
607
+ throw new UserError(
608
+ typeof exec === 'string'
609
+ ? `${where}.exec must be an object — a command is { exec: { command: ${JSON.stringify(exec)} } }`
610
+ : `${where}.exec must be an object with a \`command\` string`,
611
+ )
601
612
  }
602
613
  assertKnownFields(exec, EXEC_FIELDS, `${where}.exec`)
603
614
  const sandbox = (exec as { sandbox?: unknown }).sandbox
@@ -648,11 +659,33 @@ export function validateProjectConfig(
648
659
  )
649
660
  }
650
661
  }
662
+ const worker = (exec as { worker?: unknown }).worker
663
+ if (worker !== undefined && (typeof worker !== 'string' || worker.trim().length === 0)) {
664
+ throw new UserError(`${where}.exec.worker must be a worker type's name (or omitted)`)
665
+ }
651
666
  const env = (exec as { env?: unknown }).env
652
667
  if (env !== undefined) {
653
668
  if (typeof env !== 'object' || env === null) {
654
669
  throw new UserError(`${where}.exec.env must be an object (or omitted)`)
655
670
  }
671
+ // `env: { NODE_ENV: 'production' }` (Turbo's and Nx's shape): name
672
+ // the level the variable belongs at, not only the allowed fields.
673
+ const plain = Array.isArray(env)
674
+ ? undefined
675
+ : Object.entries(env).find(
676
+ ([k, v]) =>
677
+ !ENV_FIELDS.has(k) &&
678
+ // `passthrough` is a typo of a field, not a variable.
679
+ nearest(k, ENV_FIELDS) === undefined &&
680
+ ['string', 'number', 'boolean'].includes(typeof v),
681
+ )
682
+ if (plain !== undefined) {
683
+ throw new UserError(
684
+ `${where}.exec.env has unknown field "${plain[0]}" ` +
685
+ `(allowed: ${[...ENV_FIELDS].sort().join(', ')}) — vx spells it ` +
686
+ `exec.env.define: { ${plain[0]}: ${JSON.stringify(String(plain[1]))} }`,
687
+ )
688
+ }
656
689
  // `env: { set: {...} }` loaded and defined nothing — the one level
657
690
  // without this check until 2026-09-10 (tests/schema-unknown-keys).
658
691
  assertKnownFields(env, ENV_FIELDS, `${where}.exec.env`)
@@ -806,9 +839,12 @@ export function validateProjectConfig(
806
839
  }
807
840
  if (dependsOn !== undefined) {
808
841
  if (!Array.isArray(dependsOn) || dependsOn.some((s) => typeof s !== 'string')) {
842
+ // One name, not in a list: the fix is that list.
809
843
  throw new UserError(
810
- `${where}.dependsOn must be an array of strings ` +
811
- `(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name')`,
844
+ typeof dependsOn === 'string'
845
+ ? `${where}.dependsOn must be an array — write dependsOn: [${JSON.stringify(dependsOn)}]`
846
+ : `${where}.dependsOn must be an array of strings ` +
847
+ `(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name')`,
812
848
  )
813
849
  }
814
850
  }
@@ -924,6 +960,10 @@ export function validateProjectConfig(
924
960
  throw err
925
961
  }
926
962
  }
963
+ // The selection starts empty and a `!` entry only subtracts, so a
964
+ // list of negations folded no dependency and the key stopped moving
965
+ // with every upstream, as a negation-only inputs.files does.
966
+ assertTaskFiltersNotNegationOnly(taskFilters, `${where}.inputs.tasks`)
927
967
  assertFilterNamesDeclaredDeps(taskFilters, task.dependsOn, where)
928
968
  }
929
969
  for (const field of ['runtime', 'workspaceRuntime'] as const) {
@@ -1078,6 +1118,7 @@ const EXEC_FIELDS = new Set([
1078
1118
  'remote',
1079
1119
  'sandbox',
1080
1120
  'platforms',
1121
+ 'worker',
1081
1122
  ])
1082
1123
  const PERSISTENT_FIELDS = new Set(['readyWhen'])
1083
1124
  const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
@@ -1274,7 +1315,11 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
1274
1315
  if (!allowed.has(key)) {
1275
1316
  // The nearest accepted spelling first: the list says what the level
1276
1317
  // takes, the hint says which one was meant.
1277
- const foreign = FOREIGN_FIELDS.get(allowed)?.[key] ?? nestedHome(allowed, key)
1318
+ // Own keys only: `constructor` read Object's and printed its source.
1319
+ const spellings = FOREIGN_FIELDS.get(allowed)
1320
+ const foreign =
1321
+ (spellings !== undefined && Object.hasOwn(spellings, key) ? spellings[key] : undefined) ??
1322
+ nestedHome(allowed, key)
1278
1323
  const near =
1279
1324
  nearest(key, allowed) ?? (allowed === WORKSPACE_FIELDS ? nearestSetting(key) : undefined)
1280
1325
  throw new UserError(
@@ -1384,14 +1429,25 @@ function assertNoForeignToken(glob: string, where: string): void {
1384
1429
  * and names both.
1385
1430
  */
1386
1431
  function assertNoGluedGlobstar(glob: string, where: string): void {
1432
+ if (gluesGlobstar(glob)) throw gluedGlobstar(glob, `"${glob}"`, where)
1433
+ // Split on `{` `}` `,` alone, `src/{a,**}.ts` passed and expanded to
1434
+ // `src/**.ts`, one level.
1435
+ const alt = braceAlternatives(glob).find(gluesGlobstar)
1436
+ if (alt !== undefined) throw gluedGlobstar(alt, `"${glob}" expands to "${alt}": that`, where)
1437
+ }
1438
+
1439
+ function gluesGlobstar(glob: string): boolean {
1387
1440
  const body = glob.startsWith('!') ? glob.slice(1) : glob
1388
- if (!body.split(/[/{},]/).some((seg) => seg.includes('**') && /[^*]/.test(seg))) return
1441
+ return body.split(/[/{},]/).some((seg) => seg.includes('**') && /[^*]/.test(seg))
1442
+ }
1443
+
1444
+ function gluedGlobstar(glob: string, subject: string, where: string): UserError {
1389
1445
  const deep = glob.replace(/\*\*([^*/{},]+)/g, '**/*$1').replace(/([^*/{},]+)\*\*/g, '$1*/**')
1390
1446
  const one = glob.replace(/\*{2,}/g, (run, at: number) =>
1391
1447
  /[^*/{},]/.test(glob[at - 1] ?? '') || /[^*/{},]/.test(glob[at + run.length] ?? '') ? '*' : run,
1392
1448
  )
1393
- throw new UserError(
1394
- `${where}: "${glob}" glues ** to other text in one path segment, which vx's glob engine ` +
1449
+ return new UserError(
1450
+ `${where}: ${subject} glues ** to other text in one path segment, which vx's glob engine ` +
1395
1451
  `reads as one *, a single directory level. Write "${deep}" for every depth, or "${one}" ` +
1396
1452
  `for one level.`,
1397
1453
  )
@@ -1663,8 +1719,8 @@ function isEnvName(name: unknown): name is string {
1663
1719
  * A task name `dependsOn`, `inputs.tasks` and the CLI can name. Each
1664
1720
  * refused shape loaded and could not be referenced: `x#y` read as project
1665
1721
  * `x`, `^gen` as every dependency's `gen`, a `*` as a pattern, `''` ran
1666
- * under `--all` as `a#` and `vx run a#` refused it, and a padded name was
1667
- * trimmed away by the reader (item 1000).
1722
+ * under `--all` as `a#` and `vx run a#` refused it, `-x` as a flag, and a
1723
+ * padded name was trimmed away by the reader (item 1000).
1668
1724
  */
1669
1725
  function assertTaskName(name: string, configPath: string): void {
1670
1726
  const why = taskNameProblem(name)
@@ -1687,9 +1743,11 @@ export function taskNameProblem(name: string): string | null {
1687
1743
  ? "holds '*', which makes it a pattern"
1688
1744
  : name.startsWith('^') || name.startsWith('!')
1689
1745
  ? `starts with '${name[0]}', which names dependencies' tasks or negates`
1690
- : hasControl(name)
1691
- ? 'holds a control character'
1692
- : null
1746
+ : name.startsWith('-')
1747
+ ? "starts with '-', which the CLI reads as a flag"
1748
+ : hasControl(name)
1749
+ ? 'holds a control character'
1750
+ : null
1693
1751
  }
1694
1752
 
1695
1753
  /**
@@ -1741,6 +1799,18 @@ function assertNotNegationOnly(globs: readonly string[], where: string): void {
1741
1799
  )
1742
1800
  }
1743
1801
 
1802
+ function assertTaskFiltersNotNegationOnly(filters: readonly string[], where: string): void {
1803
+ if (filters.length === 0) return
1804
+ if (filters.some((f) => !f.startsWith('!'))) return
1805
+ throw new UserError(
1806
+ `${where}: every entry is a negation, which selects NOTHING (got ${JSON.stringify(filters)}) — ` +
1807
+ `negations only subtract from the dependencies a positive entry already selected, so this ` +
1808
+ `task would fold no upstream hash and its cache key would stop tracking its dependencies. ` +
1809
+ `Add the positive entry you meant, e.g. ['*', '^*', ${JSON.stringify(filters[0])}], ` +
1810
+ `or use [] to decouple on purpose.`,
1811
+ )
1812
+ }
1813
+
1744
1814
  /**
1745
1815
  * A `!` entry subtracts wherever it sits in the list, so a file named
1746
1816
  * outright after it (`['src/**', '!src/gen/**', 'src/gen/keep.ts']`) never
@@ -1860,6 +1930,19 @@ function assertStringArray(v: unknown, where: string): void {
1860
1930
  }
1861
1931
  }
1862
1932
 
1933
+ /**
1934
+ * Only `~` and `~/…` mean the home: `~root/x/` was read as `$HOME/root/x`,
1935
+ * and vx created that directory on the host.
1936
+ */
1937
+ function assertOwnHome(v: readonly string[], where: string): void {
1938
+ const other = v.find((p) => p.startsWith('~') && p !== '~' && !p.startsWith('~/'))
1939
+ if (other === undefined) return
1940
+ throw new UserError(
1941
+ `${where}: ${JSON.stringify(other)} names a user's home, which vx does not expand — ` +
1942
+ `write '~/…' for your own home, or an absolute path`,
1943
+ )
1944
+ }
1945
+
1863
1946
  /**
1864
1947
  * Validate a `sandbox: {...}` block — one capability shape, whatever the
1865
1948
  * platform ends up doing with it. Unknown keys are refused rather than
@@ -1892,7 +1975,22 @@ function validateSandbox(sandbox: unknown, where: string): void {
1892
1975
  for (const f of GRANT_PATH_FIELDS) {
1893
1976
  // Patterns are allowed here: macOS matches them natively, Linux
1894
1977
  // expands them against the filesystem at resolve time.
1895
- if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
1978
+ if (g[f] === undefined) continue
1979
+ assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
1980
+ assertOwnHome(g[f] as string[], `${where}.sandbox.allow.${f}`)
1981
+ }
1982
+ // SRT's Linux observer reports a write's path with each control
1983
+ // character turned into a space, so a write a grant holding one covers
1984
+ // read as a violation of the path spelled with a space, and a real
1985
+ // write there reads the same: no match passes one and not the other.
1986
+ // Reads are traced by strace, which escapes them.
1987
+ const ctl = ((g['write'] as string[] | undefined) ?? []).find(hasControl)
1988
+ if (ctl !== undefined) {
1989
+ throw new UserError(
1990
+ `${where}.sandbox.allow.write: ${JSON.stringify(ctl)} holds a control character, ` +
1991
+ `which the Linux sandbox's report of a write does not keep (it spells a tab as a ` +
1992
+ `space), so a write under it cannot be told from a refused one — rename it`,
1993
+ )
1896
1994
  }
1897
1995
  for (const f of GRANT_NAME_FIELDS) {
1898
1996
  if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
@@ -1949,7 +2047,10 @@ function validateSandbox(sandbox: unknown, where: string): void {
1949
2047
  // Patterns, not prefixes — `ignore` is vx's own filter over lines it
1950
2048
  // already holds, so a glob costs nothing and works on every platform.
1951
2049
  for (const f of IGNORE_FIELDS) {
1952
- if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.ignore.${f}`)
2050
+ if (g[f] === undefined) continue
2051
+ assertStringArray(g[f], `${where}.sandbox.ignore.${f}`)
2052
+ if (f === 'read' || f === 'write')
2053
+ assertOwnHome(g[f] as string[], `${where}.sandbox.ignore.${f}`)
1953
2054
  }
1954
2055
  }
1955
2056
  }
@@ -11,8 +11,8 @@
11
11
  // changed lockfile's base digests are there, so the base side is never
12
12
  // parsed. `vx lock --check` holds it to a fresh derivation.
13
13
 
14
- import { rename, rm } from 'node:fs/promises'
15
14
  import path from 'node:path'
15
+ import type { PendingWrite } from '../util/index.js'
16
16
  import type { ProjectMeta } from './workspace.js'
17
17
  import { lockKey } from './lockfile.js'
18
18
  import { type LoadReads, readOnce } from './load-reads.js'
@@ -90,19 +90,12 @@ function dependenciesLockText(lock: DependenciesLock): string {
90
90
  return `${JSON.stringify(lock)}\n`
91
91
  }
92
92
 
93
- /** Written beside its name and renamed over it, as `writeLockfile`: a reader never sees half a file. */
94
- export async function writeDependenciesLock(root: string, lock: DependenciesLock): Promise<void> {
95
- const file = path.join(root, DEPENDENCIES_LOCK_NAME)
96
- const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
97
- try {
98
- await Bun.write(tmp, dependenciesLockText(lock))
99
- await rename(tmp, file)
100
- } catch (err) {
101
- await rm(tmp, { force: true }).catch(() => {})
102
- const e = err as NodeJS.ErrnoException
103
- if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
104
- if (e.path === tmp) e.path = file
105
- throw err
93
+ /** The dependency lock's file and bytes, for `writeAllOrNone`. */
94
+ export function dependenciesLockWrite(root: string, lock: DependenciesLock): PendingWrite {
95
+ return {
96
+ file: path.join(root, DEPENDENCIES_LOCK_NAME),
97
+ text: dependenciesLockText(lock),
98
+ shown: DEPENDENCIES_LOCK_NAME,
106
99
  }
107
100
  }
108
101
 
@@ -131,9 +131,18 @@ export function parseFilter(
131
131
  if (scoped !== null && !scoped[1]!.startsWith('./') && scoped[1] !== '.') {
132
132
  s = scoped[1]!
133
133
  gitSince = scoped[2]!
134
+ // Neither Turbo nor pnpm reads it (both keep `^...` in the name):
135
+ // `{dir}^...[ref]` dropped the `^...` and selected the inverse, and
136
+ // the name spellings matched nothing.
137
+ if (s.endsWith('^...'))
138
+ throw new UserError(
139
+ `filter "${raw}" puts ^... before a git ref, which no form reads: ` +
140
+ `<sel>...[<ref>] is <sel> where it or a dependency changed, ` +
141
+ `<sel>[<ref>]^... the dependencies of the changed ones`,
142
+ )
134
143
  // The name glob kept the `...` and matched nothing (create-t3-turbo,
135
144
  // `@acme/*...[HEAD]`).
136
- if (s.endsWith('...') && !s.endsWith('^...')) {
145
+ if (s.endsWith('...')) {
137
146
  s = s.slice(0, -3)
138
147
  sinceViaDeps = true
139
148
  }
@@ -28,6 +28,7 @@ export {
28
28
  lockKey,
29
29
  NO_LOCK,
30
30
  readLockfile,
31
+ lockfileWrite,
31
32
  writeLockfile,
32
33
  type Lockfile,
33
34
  type LockedProject,
@@ -38,7 +39,7 @@ export {
38
39
  parseDependenciesLock,
39
40
  readDependenciesLock,
40
41
  recordedDigests,
41
- writeDependenciesLock,
42
+ dependenciesLockWrite,
42
43
  type DependenciesLock,
43
44
  } from './dependencies-lock.js'
44
45
  export {
@@ -60,6 +61,7 @@ export {
60
61
  validateWorkspace,
61
62
  } from './config-schema.js'
62
63
  export { configImports } from './config-imports.js'
64
+ export { repoIdsOf } from './repo-id.js'
63
65
  export {
64
66
  checkProjectNames,
65
67
  findWorkspaceRoot,
@@ -8,10 +8,14 @@
8
8
  // worker's, which never saw it — or, where the validator does not type
9
9
  // the value, it was silently outside the key on both. So every path
10
10
  // refuses it, before schema validation, by this one rule: the first load
11
- // on the live object (`validateProjectConfig`), the worker before its
11
+ // on the live object before its JSON copy (project-loader.ts), the worker before its
12
12
  // `JSON.stringify` (config-eval.ts). The worker embeds `nonJsonPaths` by its source text, so
13
13
  // the function must stay self-contained: no import, no module-scope name.
14
14
  //
15
+ // JSON writes only own enumerable string keys: a symbol key or a
16
+ // non-enumerable property vanished from the key and the lock
17
+ // (`"config":{}`), so it is refused too.
18
+ //
15
19
  // An `undefined` PROPERTY is allowed: JSON drops it, the validator reads
16
20
  // it as absent, and configs write it with conditional spreads. Inside an
17
21
  // array it is refused, because JSON writes it as `null`.
@@ -29,13 +33,20 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
29
33
  // The key or index at each level, rendered only for a finding: the walk
30
34
  // runs on every first load, and a path string built per node cost three
31
35
  // times the schema check itself.
32
- const keys: Array<string | number> = []
36
+ const keys: Array<string | number | symbol> = []
33
37
  // The objects on the path to the one being walked: a cycle throws in
34
38
  // `JSON.stringify` and would recurse here forever. A DAG is data.
35
39
  const ancestors: object[] = []
40
+ const MAX_DEPTH = 256
36
41
  const found = (is: string): void => {
37
42
  let path = ''
38
- for (const k of keys) path += typeof k === 'number' ? `[${k}]` : path === '' ? k : `.${k}`
43
+ for (const k of keys)
44
+ path +=
45
+ typeof k === 'number' || typeof k === 'symbol'
46
+ ? `[${String(k)}]`
47
+ : path === ''
48
+ ? k
49
+ : `.${k}`
39
50
  out.push({ path: path === '' ? 'the default export' : path, is })
40
51
  }
41
52
  const walk = (v: unknown, inArray: boolean): void => {
@@ -45,6 +56,12 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
45
56
  else if (typeof v === 'number' && !Number.isFinite(v)) found(String(v))
46
57
  else if (v === undefined && inArray) found('undefined in an array, which JSON writes as null')
47
58
  else if (typeof v === 'object' && v !== null) {
59
+ // Deeper, the walk and `JSON.stringify` overflowed the stack: a
60
+ // config 100,000 levels deep was vx's own RangeError (VX_E_INTERNAL).
61
+ if (ancestors.length >= MAX_DEPTH) {
62
+ found(`nested deeper than ${MAX_DEPTH} levels`)
63
+ return
64
+ }
48
65
  if (ancestors.includes(v)) {
49
66
  found('a cyclic reference')
50
67
  return
@@ -57,6 +74,13 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
57
74
  walk(v[i], true)
58
75
  keys.pop()
59
76
  }
77
+ // Symbols only: listing an array's own names allocates every index
78
+ // string, +1-4% on a 20k-task lock, for a named property no config writes.
79
+ for (const key of Object.getOwnPropertySymbols(v)) {
80
+ keys.push(key)
81
+ found('a symbol key, which JSON drops')
82
+ keys.pop()
83
+ }
60
84
  ancestors.pop()
61
85
  return
62
86
  }
@@ -71,16 +95,18 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
71
95
  return
72
96
  }
73
97
  ancestors.push(v)
74
- for (const key of Object.keys(v)) {
98
+ for (const key of Reflect.ownKeys(v)) {
75
99
  keys.push(key)
76
100
  // Read by descriptor: a getter is code, and each read may answer
77
101
  // differently. `vx show` read one three times, so the key, the
78
102
  // check and the command could each see another value, and a
79
103
  // throwing one surfaced as vx's own stack (D-67). Never called here.
80
- const d = Object.getOwnPropertyDescriptor(v, key)
81
- if (d !== undefined && d.get !== undefined) found('a getter')
82
- else if (d !== undefined && d.set !== undefined) found('a setter')
83
- else walk(d?.value, false)
104
+ const d = Object.getOwnPropertyDescriptor(v, key) as PropertyDescriptor
105
+ if (typeof key === 'symbol') found('a symbol key, which JSON drops')
106
+ else if (d.enumerable !== true) found('a non-enumerable property, which JSON drops')
107
+ else if (d.get !== undefined) found('a getter')
108
+ else if (d.set !== undefined) found('a setter')
109
+ else walk(d.value, false)
84
110
  keys.pop()
85
111
  }
86
112
  ancestors.pop()
@@ -11,11 +11,18 @@
11
11
  // eval-free.
12
12
  // - `vx lock --check` AUDITS it: full re-evaluation + deep equality.
13
13
 
14
- import { readFileSync } from 'node:fs'
15
- import { rename, rm } from 'node:fs/promises'
14
+ import { readFileSync, statSync } from 'node:fs'
16
15
  import path from 'node:path'
17
16
  import type { ProjectConfig } from '../config.js'
18
- import { keysSorted, relPosix, UserError, xxh3, xxh3hex } from '../util/index.js'
17
+ import {
18
+ keysSorted,
19
+ type PendingWrite,
20
+ relPosix,
21
+ UserError,
22
+ writeAllOrNone,
23
+ xxh3,
24
+ xxh3hex,
25
+ } from '../util/index.js'
19
26
  import { VERSION } from '../version.js'
20
27
  import { validateProjectConfig } from './config-schema.js'
21
28
  import { PROJECT_CONFIG_FILENAMES } from './workspace.js'
@@ -38,6 +45,12 @@ export interface Lockfile {
38
45
  * by directory, so a name a plugin gives never invalidates the lock.
39
46
  */
40
47
  projects: Record<string, LockedProject>
48
+ /**
49
+ * Root-relative POSIX directories holding their own `.vx/workspace.*`,
50
+ * where every outer project's outputs stop. Absent when there is none, so a
51
+ * workspace without one locks the bytes it always did.
52
+ */
53
+ nestedWorkspaces?: string[]
41
54
  /** Written by `writeLockfile` from the bytes; never read off the object. */
42
55
  validated?: string
43
56
  }
@@ -101,7 +114,16 @@ export function lockDigest(lock: Lockfile): string | undefined {
101
114
  */
102
115
  export async function readLockfile(root: string): Promise<Lockfile | null> {
103
116
  const file = Bun.file(lockfilePath(root))
104
- if (!(await file.exists())) return null
117
+ if (!(await file.exists())) {
118
+ // A directory reads as no file: `--check` said "run `vx lock`", which cannot write over it.
119
+ if (statSync(lockfilePath(root), { throwIfNoEntry: false })?.isDirectory()) {
120
+ throw new UserError(
121
+ `${LOCKFILE_NAME} is a directory — remove it and run \`vx lock\``,
122
+ 'VX_E_LOCK',
123
+ )
124
+ }
125
+ return null
126
+ }
105
127
  let parsed: unknown
106
128
  let text: string
107
129
  try {
@@ -123,7 +145,7 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
123
145
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
124
146
  throw new UserError(`${LOCKFILE_NAME} must be a JSON object — re-run \`vx lock\``, 'VX_E_LOCK')
125
147
  }
126
- const lock = parsed as { version?: unknown; projects?: unknown }
148
+ const lock = parsed as { version?: unknown; projects?: unknown; nestedWorkspaces?: unknown }
127
149
  if (lock.version !== LOCKFILE_VERSION) {
128
150
  throw new UserError(
129
151
  `${LOCKFILE_NAME} has unsupported version ${String(lock.version)} ` +
@@ -153,21 +175,47 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
153
175
  Array.isArray(entry) ||
154
176
  typeof file !== 'string' ||
155
177
  !PROJECT_CONFIG_FILENAMES.includes(file) ||
156
- typeof config !== 'object' ||
157
- config === null ||
158
- Array.isArray(config)
178
+ !isObj(config) ||
179
+ // A run trusts these shapes: a string `name`, a `tags` list, a `tasks` object.
180
+ (config['name'] !== undefined && typeof config['name'] !== 'string') ||
181
+ (config['tags'] !== undefined && !Array.isArray(config['tags'])) ||
182
+ (config['tasks'] !== undefined && !isObj(config['tasks']))
159
183
  ) {
160
- throw new UserError(
161
- `${LOCKFILE_NAME}: entry for "${dir}" is malformed — re-run \`vx lock\``,
162
- 'VX_E_LOCK',
163
- )
184
+ throw malformed(dir)
164
185
  }
165
186
  }
187
+ const nested = lock.nestedWorkspaces
188
+ if (
189
+ nested !== undefined &&
190
+ (!Array.isArray(nested) ||
191
+ nested.some((d) => typeof d !== 'string' || d === '.' || !isLockKey(d)))
192
+ ) {
193
+ throw new UserError(
194
+ `${LOCKFILE_NAME}: \`nestedWorkspaces\` must list directories under the workspace root — re-run \`vx lock\``,
195
+ 'VX_E_LOCK',
196
+ )
197
+ }
166
198
  pooledIn(parsed as Pooled)
199
+ // After the pool is put back, as a task may be an index into it. A
200
+ // string `dependsOn` was walked per character: "dependsOn b#a".
201
+ for (const [dir, entry] of Object.entries(lock.projects as Record<string, LockedProject>)) {
202
+ const tasks = entry.config.tasks as Obj | undefined
203
+ for (const name in tasks) {
204
+ const t = tasks[name]
205
+ if (!isObj(t) || (t['dependsOn'] !== undefined && !Array.isArray(t['dependsOn'])))
206
+ throw malformed(dir)
207
+ }
208
+ }
167
209
  digests.set(parsed as Lockfile, xxh3hex(text))
168
210
  return parsed as Lockfile
169
211
  }
170
212
 
213
+ const malformed = (dir: string): UserError =>
214
+ new UserError(
215
+ `${LOCKFILE_NAME}: entry for "${dir}" is malformed — re-run \`vx lock\``,
216
+ 'VX_E_LOCK',
217
+ )
218
+
171
219
  /**
172
220
  * The project entries of a lock's bytes with its pool put back, or null
173
221
  * for bytes that do not parse as a lock: an entry compares across two
@@ -335,31 +383,32 @@ function taskKeysSorted(key: string, value: unknown): unknown {
335
383
  return Object.fromEntries(Object.entries(value).map(([name, task]) => [name, keysSorted(task)]))
336
384
  }
337
385
 
338
- export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
339
- // Written beside its name and renamed over it: `Bun.write` truncates in
340
- // place, so a run, or `vx watch` woken by the write
341
- // itself (item 971), could read an empty or half-written lock and fail
342
- // on "not valid JSON" (item 972).
343
- const file = lockfilePath(root)
344
- const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
345
- try {
346
- // Minified: the file only vx reads, and it loads faster than pretty
347
- // JSON (measured, 10k projects: 15.6 ms against 22.5 ms). `vx.lock.json`
348
- // beside each config is the copy for people.
349
- const sorted = JSON.parse(JSON.stringify(lock.projects, taskKeysSorted)) as Lockfile['projects']
350
- const body = JSON.stringify({ version: lock.version, ...pooledOut(sorted) })
351
- await Bun.write(tmp, `${body.slice(0, -1)},"validated":"${validatedDigest(body)}"}\n`)
352
- await rename(tmp, file)
353
- } catch (err) {
354
- // Best effort: where the write was refused the temp cannot be named
355
- // either (ENOTDIR), and that must not replace the refusal itself.
356
- await rm(tmp, { force: true }).catch(() => {})
357
- // The refusal names the file the user knows: a read-only checkout read
358
- // `open '…/.vx/projects.lock.json.tmp-…'` where it had read the lock's own name.
359
- const e = err as NodeJS.ErrnoException
360
- if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
361
- if (e.path === tmp) e.path = file
362
- throw err
386
+ /**
387
+ * Written beside its name and renamed over it: `Bun.write` truncates in
388
+ * place, so a run, or `vx watch` woken by the write itself (item 971),
389
+ * could read an empty or half-written lock and fail on "not valid JSON"
390
+ * (item 972).
391
+ */
392
+ export function writeLockfile(root: string, lock: Lockfile): Promise<void> {
393
+ return writeAllOrNone([lockfileWrite(root, lock)], root)
394
+ }
395
+
396
+ /** The lock's file and bytes, for `writeAllOrNone`. */
397
+ export function lockfileWrite(root: string, lock: Lockfile): PendingWrite {
398
+ // Minified: the file only vx reads, and it loads faster than pretty
399
+ // JSON (measured, 10k projects: 15.6 ms against 22.5 ms). `vx.lock.json`
400
+ // beside each config is the copy for people.
401
+ const sorted = JSON.parse(JSON.stringify(lock.projects, taskKeysSorted)) as Lockfile['projects']
402
+ const nested = lock.nestedWorkspaces ?? []
403
+ const body = JSON.stringify({
404
+ version: lock.version,
405
+ ...(nested.length > 0 ? { nestedWorkspaces: nested } : {}),
406
+ ...pooledOut(sorted),
407
+ })
408
+ return {
409
+ file: lockfilePath(root),
410
+ text: `${body.slice(0, -1)},"validated":"${validatedDigest(body)}"}\n`,
411
+ shown: LOCKFILE_NAME,
363
412
  }
364
413
  }
365
414