@vzn/vx 0.0.595 → 0.0.597

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/launcher.cjs +7 -3
  3. package/package.json +5 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +170 -99
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/git-inputs.ts +23 -0
  8. package/src/cache/index.ts +3 -0
  9. package/src/cache/inputs.ts +99 -44
  10. package/src/cache/key-fold.ts +1 -1
  11. package/src/cache/layer.ts +9 -6
  12. package/src/cache/policy.ts +5 -0
  13. package/src/cache/zstd.ts +10 -4
  14. package/src/cli/cache.ts +12 -6
  15. package/src/cli/foreign-flags.ts +57 -3
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +10 -4
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +4 -4
  21. package/src/cli/run.ts +73 -30
  22. package/src/cli/select.ts +6 -3
  23. package/src/cli/show.ts +8 -2
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-filter.ts +63 -12
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch-judge.ts +15 -8
  28. package/src/cli/watch.ts +18 -11
  29. package/src/cli/workspace-config.ts +3 -1
  30. package/src/config.ts +42 -21
  31. package/src/exec/executor.ts +1 -1
  32. package/src/exec/kill-tree.ts +89 -18
  33. package/src/exec/proc-sample.ts +1 -1
  34. package/src/exec/runner.ts +74 -36
  35. package/src/exec/sandbox-paths.ts +12 -1
  36. package/src/exec/sandbox-runtime.ts +283 -88
  37. package/src/exec/sandbox-violations.ts +115 -15
  38. package/src/graph/scheduler.ts +30 -14
  39. package/src/graph/task-graph.ts +265 -44
  40. package/src/orchestrator/admission.ts +12 -3
  41. package/src/orchestrator/affected-tasks.ts +6 -2
  42. package/src/orchestrator/deferred-outputs.ts +3 -2
  43. package/src/orchestrator/doctor.ts +2 -2
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/excluded-keys.ts +2 -13
  46. package/src/orchestrator/execute-task.ts +85 -39
  47. package/src/orchestrator/failure-mode.ts +43 -28
  48. package/src/orchestrator/framed-output.ts +8 -8
  49. package/src/orchestrator/index.ts +0 -1
  50. package/src/orchestrator/keyed-projects.ts +6 -3
  51. package/src/orchestrator/logger.ts +21 -9
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +9 -13
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +7 -23
  56. package/src/orchestrator/plugin.ts +37 -16
  57. package/src/orchestrator/prepare.ts +13 -4
  58. package/src/orchestrator/run-artifacts.ts +4 -3
  59. package/src/orchestrator/run-lock.ts +25 -24
  60. package/src/orchestrator/run.ts +22 -5
  61. package/src/orchestrator/sandbox-request.ts +96 -18
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +7 -13
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/telemetry.ts +16 -7
  68. package/src/orchestrator/upstream.ts +11 -0
  69. package/src/util/hangup.ts +42 -0
  70. package/src/util/index.ts +2 -0
  71. package/src/util/paths.ts +24 -4
  72. package/src/workspace/affected.ts +79 -42
  73. package/src/workspace/config-eval.ts +27 -6
  74. package/src/workspace/config-imports.ts +8 -53
  75. package/src/workspace/config-schema.ts +56 -9
  76. package/src/workspace/filter.ts +19 -0
  77. package/src/workspace/lockfile.ts +2 -1
  78. package/src/workspace/migrate-scripts.ts +5 -3
  79. package/src/workspace/migration.ts +1 -1
  80. package/src/workspace/project-loader.ts +19 -4
  81. package/src/workspace/repo-id.ts +42 -4
  82. package/src/workspace/workspace.ts +53 -24
@@ -84,7 +84,10 @@ export interface AffectedArgs {
84
84
  * (item 954). The `--affected` sugar's graph walk has already staged every
85
85
  * config, so there it costs nothing more.
86
86
  */
87
- workspaceGlobOwners?: (changedPaths: readonly string[]) => Promise<Iterable<string>>
87
+ workspaceGlobOwners?: (
88
+ changedPaths: readonly string[],
89
+ nested: readonly string[],
90
+ ) => Promise<Iterable<string>>
88
91
  /**
89
92
  * The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
90
93
  * answer for a change to one. Resolved lazily: loading the workspace
@@ -142,6 +145,12 @@ export interface AffectedChanges {
142
145
  /** Project → its changed paths, relative to the project's directory. */
143
146
  paths: ReadonlyMap<string, readonly string[]>
144
147
  whole: ReadonlySet<string>
148
+ /**
149
+ * The changed paths that are nested repositories (a submodule, an
150
+ * embedded repository), workspace-relative: each stands for every file
151
+ * in it, so a `workspaceFiles` glob that reaches under one is reached.
152
+ */
153
+ nested?: readonly string[]
145
154
  }
146
155
 
147
156
  export async function affectedChanges(
@@ -221,16 +230,10 @@ export async function affectedChanges(
221
230
  // submodules (`diff.ignoreSubmodules`, `submodule.<name>.ignore`), and
222
231
  // then an edit inside one, or a committed bump, was no change here while
223
232
  // the task's key moved (item 951).
224
- gitPaths(args.workspaceRoot, [
225
- 'diff',
226
- '--no-renames',
227
- '--ignore-submodules=none',
228
- '--relative',
229
- '--name-only',
230
- '-z',
231
- '--end-of-options',
232
- base,
233
- ]),
233
+ //
234
+ // `--raw` for each path's mode: a gitlink (160000) is a nested
235
+ // repository, one path standing for every file in it (`nested` below).
236
+ gitDiffPaths(args.workspaceRoot, base),
234
237
  // `git diff` never reports untracked-but-not-ignored files, but input
235
238
  // enumeration does (`git ls-files --cached --others --exclude-standard`),
236
239
  // so a brand-new source file changes a task's cache key. Union it in or
@@ -244,9 +247,13 @@ export async function affectedChanges(
244
247
  // file: re-running `vx lock` must not mark every project affected.
245
248
  // An untracked file under `node_modules` is an install, which the input
246
249
  // enumeration drops too (`isInstalledPath`); a tracked one is a source.
247
- const changed = [...diffed, ...untracked.filter((s) => !isInstalledPath(s))].filter(
250
+ const changed = [...diffed.paths, ...untracked.filter((s) => !isInstalledPath(s))].filter(
248
251
  (s) => s !== LOCKFILE_NAME,
249
252
  )
253
+ // The nested repositories git reports as one path: a gitlink, or an
254
+ // untracked embedded repository (`dir/`, the only path git prints so).
255
+ const nested = new Set(diffed.gitlinks)
256
+ for (const rel of untracked) if (rel.endsWith('/')) nested.add(rel)
250
257
 
251
258
  // A lockfile or workspace-definition change re-keys EVERY task, because the
252
259
  // workspace fingerprint folds those files into every cache key. Mapping
@@ -316,6 +323,7 @@ export async function affectedChanges(
316
323
  const owned = projectsContaining(
317
324
  args.workspaceRoot,
318
325
  changed,
326
+ nested,
319
327
  args.projects,
320
328
  realDirs,
321
329
  perTask
@@ -371,15 +379,21 @@ export async function affectedChanges(
371
379
  projects: args.projects,
372
380
  changed,
373
381
  skip: perTask ? new Set() : owned,
374
- realDirs,
375
382
  })) {
376
383
  owned.add(name)
377
384
  whole.add(name)
378
385
  }
379
386
 
380
- const done = (): AffectedChanges => ({ projects: owned, changed, paths, whole })
387
+ const nestedDirs = [...nested].map((rel) => rel.replace(/\/$/, ''))
388
+ const done = (): AffectedChanges => ({
389
+ projects: owned,
390
+ changed,
391
+ paths,
392
+ whole,
393
+ ...(nestedDirs.length > 0 ? { nested: nestedDirs } : {}),
394
+ })
381
395
  if (changed.length === 0 || args.workspaceGlobOwners === undefined) return done()
382
- for (const name of await args.workspaceGlobOwners(changed)) owned.add(name)
396
+ for (const name of await args.workspaceGlobOwners(changed, nestedDirs)) owned.add(name)
383
397
  return done()
384
398
  }
385
399
 
@@ -645,6 +659,37 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
645
659
  return stdout.split('\0').filter((s) => s.length > 0)
646
660
  }
647
661
 
662
+ /**
663
+ * `git diff --raw -z <base>`'s paths, and those that are a gitlink on
664
+ * either side: one the change removed took its files with it. Each record
665
+ * is `:<mode> <mode> <oid> <oid> <status>` then its path (one path:
666
+ * `--no-renames`).
667
+ */
668
+ async function gitDiffPaths(
669
+ workspaceRoot: string,
670
+ base: string,
671
+ ): Promise<{ paths: string[]; gitlinks: string[] }> {
672
+ const fields = await gitPaths(workspaceRoot, [
673
+ 'diff',
674
+ '--no-renames',
675
+ '--ignore-submodules=none',
676
+ '--relative',
677
+ '--raw',
678
+ '-z',
679
+ '--end-of-options',
680
+ base,
681
+ ])
682
+ const paths: string[] = []
683
+ const gitlinks: string[] = []
684
+ for (let i = 0; i + 1 < fields.length; i += 2) {
685
+ const rel = fields[i + 1]!
686
+ paths.push(rel)
687
+ const head = fields[i]!
688
+ if (head.startsWith('160000 ', 1) || head.startsWith('160000 ', 8)) gitlinks.push(rel)
689
+ }
690
+ return { paths, gitlinks }
691
+ }
692
+
648
693
  /**
649
694
  * Resolve the default base for `--affected` with no explicit value.
650
695
  * Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
@@ -826,17 +871,11 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
826
871
  )
827
872
  }
828
873
 
829
- function isDirectory(abs: string): boolean {
830
- try {
831
- return statSync(abs).isDirectory()
832
- } catch {
833
- return false
834
- }
835
- }
836
-
837
874
  function projectsContaining(
838
875
  workspaceRoot: string,
839
876
  changedRelPaths: readonly string[],
877
+ /** The changed paths that are nested repositories (`nested` in `affectedChanges`). */
878
+ nested: ReadonlySet<string>,
840
879
  projects: readonly ProjectMeta[],
841
880
  realDirs: ReadonlyMap<string, string>,
842
881
  /** Told each owned path relative to its project, and each nested repository's projects. */
@@ -868,37 +907,35 @@ function projectsContaining(
868
907
  }
869
908
  const owned = new Set<string>()
870
909
  for (const rel of changedRelPaths) {
910
+ const repo = nested.has(rel)
871
911
  let dir = path.resolve(workspaceRoot, rel).normalize('NFC')
872
- let hit = false
873
912
  for (;;) {
874
913
  const name = dirToName.get(dir)
875
914
  if (name !== undefined) {
876
915
  owned.add(name)
877
916
  on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')))
878
- hit = true
917
+ // Its files are the project's inputs, and none of them is this
918
+ // path: a task's globs cannot say whether they reach inside.
919
+ if (repo) on?.repo(name)
879
920
  break
880
921
  }
881
922
  const parent = path.dirname(dir)
882
923
  if (parent === dir) break // reached the filesystem root
883
924
  dir = parent
884
925
  }
885
- if (hit) continue
886
- // A changed path that is a DIRECTORY on disk is a nested repository —
887
- // a submodule whose checkout moved or is dirty (`vendor/sub`), an
888
- // embedded repository left untracked (`vendor/nested/`) — because git
889
- // reports nothing else as a directory. The workspace repository sees
890
- // the nested one as that single path, so a change inside is a change
891
- // to it, and every project under it is affected. Its own enumeration
892
- // already keys those projects on their files (git-inputs.ts); without
893
- // this, `--affected` after an edit inside selected none of them.
894
- const abs = path.resolve(workspaceRoot, rel)
895
- if (isDirectory(abs)) {
896
- const prefix = abs.normalize('NFC') + path.sep
897
- for (const [dir, name] of dirToName) {
898
- if (!dir.startsWith(prefix)) continue
899
- owned.add(name)
900
- on?.repo(name)
901
- }
926
+ if (!repo) continue
927
+ // A submodule whose checkout moved or is dirty (`vendor/sub`), an
928
+ // embedded repository left untracked (`vendor/nested/`): the workspace
929
+ // repository sees the nested one as that single path, so a change
930
+ // inside is a change to it, and every project under it is affected.
931
+ // Its own enumeration already keys those projects on their files
932
+ // (git-inputs.ts); without this, `--affected` after an edit inside
933
+ // selected none of them.
934
+ const prefix = path.resolve(workspaceRoot, rel).normalize('NFC') + path.sep
935
+ for (const [dir, name] of dirToName) {
936
+ if (!dir.startsWith(prefix)) continue
937
+ owned.add(name)
938
+ on?.repo(name)
902
939
  }
903
940
  }
904
941
  return owned
@@ -185,12 +185,16 @@ self.onmessage = async (e) => {
185
185
  : thrown
186
186
  const report = changed()
187
187
  umaskBack()
188
+ // A thrown string or plain object has no message, and String() of a
189
+ // null-prototype object throws here, inside the catch.
190
+ const isError = err !== null && typeof err === 'object' && typeof err.message === 'string'
188
191
  postMessage({
189
192
  id,
190
193
  ok: false,
191
- name: err?.name ?? 'Error',
192
- message: err?.message ?? String(err),
193
- stack: err?.stack ?? null,
194
+ thrown: isError ? null : globalThis.Bun.inspect(err, { compact: true }),
195
+ name: isError ? (err.name ?? 'Error') : 'Error',
196
+ message: isError ? err.message : '',
197
+ stack: isError ? (err.stack ?? null) : null,
194
198
  changed: report,
195
199
  position:
196
200
  err?.position && typeof err.position === 'object'
@@ -214,6 +218,8 @@ interface WorkerReply {
214
218
  nonJson: NonJsonValue[]
215
219
  name: string
216
220
  message: string
221
+ /** What a config threw that is not an Error, as `thrownValueMessage` shows it; null for an Error. */
222
+ thrown?: string | null
217
223
  stack: string | null
218
224
  /** A transpile error's location — `BuildMessage.position`, trimmed to what the loader reads. */
219
225
  position: { file?: string; line?: number; column?: number } | null
@@ -222,6 +228,16 @@ interface WorkerReply {
222
228
  interface Pending {
223
229
  resolve: (reply: WorkerReply) => void
224
230
  reject: (err: Error) => void
231
+ configPath: string
232
+ }
233
+
234
+ /**
235
+ * A config threw something that is not an Error: `throw 'no'` printed
236
+ * `vx: no`, naming no file, and a null-prototype object crashed vx's own
237
+ * error printer with a stack.
238
+ */
239
+ export function thrownValueMessage(kind: string, configPath: string, shown: string): string {
240
+ return `${kind} config ${configPath} threw ${shown}, which is not an Error`
225
241
  }
226
242
 
227
243
  /**
@@ -236,10 +252,11 @@ export function evalBudgetMs(): number {
236
252
  // BOUND on a worker that may be wedged, with no "no limit" reading. Falls
237
253
  // back rather than clamping, because honouring ~24.8 days would hang
238
254
  // `vx watch` forever on a worker the OS killed — and unbounded makes it 1 ms,
239
- // so EVERY config load times out instead. Both ends break the same feature.
255
+ // so EVERY config load times out instead. Both ends break the same feature,
256
+ // and `0` is the second end: it fired on the next tick (D-157).
240
257
  if (raw !== undefined && /^[0-9]+$/.test(raw)) {
241
258
  const n = Number(raw)
242
- if (n <= MAX_TIMEOUT_MS) return n
259
+ if (n > 0 && n <= MAX_TIMEOUT_MS) return n
243
260
  }
244
261
  return 30_000
245
262
  }
@@ -287,6 +304,10 @@ function acquireWorker(): Worker {
287
304
  p.resolve(msg)
288
305
  return
289
306
  }
307
+ if (typeof msg.thrown === 'string') {
308
+ p.reject(new UserError(thrownValueMessage('Project', p.configPath, msg.thrown)))
309
+ return
310
+ }
290
311
  // Rebuild the error the config actually threw. Name, message, stack
291
312
  // and a transpile error's position all survive the hop, so a broken
292
313
  // config reports the same text it would from an in-process import.
@@ -365,7 +386,7 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
365
386
  // evaluation costs, so it can only fire on a genuine wedge.
366
387
  const budget = evalBudgetMs()
367
388
  const reply = await new Promise<WorkerReply>((resolve, reject) => {
368
- pending.set(id, { resolve, reject })
389
+ pending.set(id, { resolve, reject, configPath })
369
390
  timer = setTimeout(() => {
370
391
  rejectAll(new Error(`config worker did not answer within ${budget}ms`))
371
392
  if (worker !== null) {
@@ -20,13 +20,13 @@
20
20
  // the lockfile moves, which the workspace fingerprint already covers.
21
21
  // A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
22
22
  // from disk (D-27).
23
- // - Descend only through files owned by NO project, or by a ROOT project
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
26
- // `vx.config.ts` importing `../core/src/index.ts`) records that edge
27
- // and STOPS there — following it would drag substantially all of that
28
- // project's `src/` into the closure, and the containment channel
29
- // already selects the project that owns it.
23
+ // - One reversed BFS from every config at once, each file read once
24
+ // however many configs reach it. It descends through every file in
25
+ // the workspace, another project's included: a site's `vx.config.ts`
26
+ // importing `../core/src/index.ts` evaluates what that file imports
27
+ // too, so an edit there moves the site's key. The walk stopped at the
28
+ // first file of another project, and containment selected core alone
29
+ // while the site's key moved (X-128).
30
30
 
31
31
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
32
32
  import { realpath } from 'node:fs/promises'
@@ -393,32 +393,6 @@ export interface ConfigImportOwnersArgs {
393
393
  changed: readonly string[]
394
394
  /** Already-selected projects; their configs need no scan. */
395
395
  skip: ReadonlySet<string>
396
- /**
397
- * Each project dir's realpath, when the caller already has them: the
398
- * containment pass needs the same answers, and asking twice cost 5,000
399
- * realpaths at 5,000 projects (D-24).
400
- */
401
- realDirs?: ReadonlyMap<string, string>
402
- }
403
-
404
- /**
405
- * Project dir → name, REALPATH'D — which is why this cannot reuse the index
406
- * the containment pass builds. `Bun.resolveSync` hands back realpath'd
407
- * targets, and on darwin a workspace under `os.tmpdir()` lives at
408
- * `/var/folders/…` while its realpath is `/private/var/folders/…`; comparing
409
- * the two matches nothing and fails exactly like "found no imports".
410
- */
411
- async function realDirIndex(
412
- projects: readonly ProjectMeta[],
413
- known: ReadonlyMap<string, string> | undefined,
414
- ): Promise<Map<string, string>> {
415
- const out = new Map<string, string>()
416
- await Promise.all(
417
- projects.map(async (p) => {
418
- out.set(known?.get(p.dir) ?? (await realpath(p.dir).catch(() => p.dir)), p.name)
419
- }),
420
- )
421
- return out
422
396
  }
423
397
 
424
398
  const TS_EXT = new Set(['.ts', '.mts', '.cts'])
@@ -426,18 +400,6 @@ const TS_EXT = new Set(['.ts', '.mts', '.cts'])
426
400
  /** The extensions Bun tries for a specifier that names none. */
427
401
  const RESOLVED_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']
428
402
 
429
- /** The deepest project containing `file`, or undefined when none does. */
430
- function ownerOf(file: string, dirToName: ReadonlyMap<string, string>): string | undefined {
431
- let dir = path.dirname(file)
432
- for (;;) {
433
- const name = dirToName.get(dir)
434
- if (name !== undefined) return name
435
- const parent = path.dirname(dir)
436
- if (parent === dir) return undefined
437
- dir = parent
438
- }
439
- }
440
-
441
403
  /** Projects whose config file transitively imports one of `changed`. */
442
404
  export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set<string>> {
443
405
  const selected = new Set<string>()
@@ -453,10 +415,6 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
453
415
  }),
454
416
  )
455
417
  if (roots.size === 0) return selected
456
- const dirToName = await realDirIndex(a.projects, a.realDirs)
457
- // A root project owns every file no member owns: the shared tooling the
458
- // walk descends through when the root is no project (D-41).
459
- const rootOwner = dirToName.get(workspaceRoot)
460
418
 
461
419
  // target → the files that import it. Reversed up front so one BFS from the
462
420
  // changed set answers every root at once, instead of a walk per root.
@@ -492,10 +450,7 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
492
450
  const list = importedBy.get(target)
493
451
  if (list) list.push(file)
494
452
  else importedBy.set(target, [file])
495
- // Descend ONLY through unowned files, or the root project's own:
496
- // see the header.
497
- const owner = ownerOf(target, dirToName)
498
- if (owner === undefined || owner === rootOwner) frontier.push(target)
453
+ frontier.push(target)
499
454
  }
500
455
  }
501
456
  }
@@ -76,6 +76,16 @@ function validateRules(rules: unknown, configPath: string): void {
76
76
 
77
77
  const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
78
78
 
79
+ /**
80
+ * A refused value as a message quotes it. A workspace file is evaluated
81
+ * in-process and no JSON rule precedes its schema, so a value here may be
82
+ * one `JSON.stringify` throws on (`30n`) or drops (a symbol): the refusal
83
+ * became a TypeError with a stack.
84
+ */
85
+ function shown(v: unknown): string {
86
+ return typeof v === 'bigint' ? `${v}n` : (JSON.stringify(v) ?? String(v))
87
+ }
88
+
79
89
  function validateRetention(retention: unknown, configPath: string): void {
80
90
  const where = `${configPath}: \`cacheRetention\``
81
91
  const field = (key: string) => `${configPath}: \`cacheRetention.${key}\``
@@ -93,12 +103,12 @@ function validateRetention(retention: unknown, configPath: string): void {
93
103
  (typeof olderThan !== 'string' || parseDuration(olderThan) === null)
94
104
  ) {
95
105
  throw new UserError(
96
- `${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${JSON.stringify(olderThan)})`,
106
+ `${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${shown(olderThan)})`,
97
107
  )
98
108
  }
99
109
  if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
100
110
  throw new UserError(
101
- `${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${JSON.stringify(maxSize)})`,
111
+ `${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${shown(maxSize)})`,
102
112
  )
103
113
  }
104
114
  // The bounds `vx cache prune` refuses, for the same reason: each evicts
@@ -144,6 +154,11 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
144
154
  ) {
145
155
  throw new UserError(`${configPath}: \`cacheDir\` is only whitespace — name a directory`)
146
156
  }
157
+ // No path can carry one: the mkdir's argument error was reported as an
158
+ // unwritable workspace.
159
+ if (typeof config.cacheDir === 'string' && config.cacheDir.includes('\0')) {
160
+ throw new UserError(`${configPath}: \`cacheDir\` holds a NUL, which no path can carry`)
161
+ }
147
162
  if (config.timeout !== undefined) {
148
163
  if (
149
164
  typeof config.timeout !== 'number' ||
@@ -156,8 +171,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
156
171
  }
157
172
  if (config.cacheRetention !== undefined) validateRetention(config.cacheRetention, configPath)
158
173
  if (config.affectedBase !== undefined) {
159
- // A leading '-' would reach git as an option, not a ref.
160
- if (typeof config.affectedBase !== 'string' || !/^[^-\s]\S*$/.test(config.affectedBase)) {
174
+ // A leading '-' would reach git as an option, not a ref; a NUL fails the
175
+ // spawn with an argument error naming nothing in this file.
176
+ if (
177
+ typeof config.affectedBase !== 'string' ||
178
+ !/^[^-\s\0][^\s\0]*$/.test(config.affectedBase)
179
+ ) {
161
180
  throw new UserError(`${configPath}: \`affectedBase\` must be a git ref like 'origin/main'`)
162
181
  }
163
182
  }
@@ -170,7 +189,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
170
189
  throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
171
190
  }
172
191
  const verbOwners = new Map<string, string>()
173
- const fileClaimants = new Map<string, string>()
192
+ const fileClaimants = new Map<string, { name: string; at: number }>()
174
193
  for (const [i, p] of config.plugins.entries()) {
175
194
  if (p === null || typeof p !== 'object') {
176
195
  // Nx's nx.json lists plugins by module name (`'@nx/vite/plugin'`);
@@ -190,6 +209,13 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
190
209
  // one whose `name` was overwritten after the stamp, is refused here —
191
210
  // the one boundary every plugin crosses.
192
211
  const pkg = (p as Record<symbol, unknown>)[PLUGIN_PACKAGE]
212
+ // An async factory called without `await` was told its plugin did not
213
+ // come from definePlugin — it had (D-159).
214
+ if (pkg === undefined && typeof (p as { then?: unknown }).then === 'function') {
215
+ throw new UserError(
216
+ `${configPath}: \`plugins[${i}]\` is a Promise — await the factory that returns the plugin`,
217
+ )
218
+ }
193
219
  if (typeof pkg !== 'string' || pkg.length === 0) {
194
220
  throw new UserError(
195
221
  `${configPath}: \`plugins[${i}]\` must come from definePlugin(import.meta, { … }) — a plugin's name is its package name`,
@@ -308,16 +334,23 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
308
334
  /[/\\]/.test(file)
309
335
  ) {
310
336
  throw new UserError(
311
- `${configPath}: plugin '${plug.name}' claims fingerprint file ${JSON.stringify(file)}, which is not a file name at the workspace root`,
337
+ `${configPath}: plugin '${plug.name}' claims fingerprint file ${shown(file)}, which is not a file name at the workspace root`,
312
338
  )
313
339
  }
314
340
  const owner = fileClaimants.get(file)
341
+ // One package twice (`plugins: [bun(), bun()]`) read "plugins 'x'
342
+ // and 'x' both claim" (D-158).
343
+ if (owner !== undefined && owner.name === plug.name) {
344
+ throw new UserError(
345
+ `${configPath}: plugin '${plug.name}' claims fingerprint file '${file}' twice (plugins[${owner.at}] and plugins[${i}]) — a file has one claimant; declare the plugin once`,
346
+ )
347
+ }
315
348
  if (owner !== undefined) {
316
349
  throw new UserError(
317
- `${configPath}: plugins '${owner}' and '${plug.name}' both claim fingerprint file '${file}' — a file has one claimant`,
350
+ `${configPath}: plugins '${owner.name}' and '${plug.name}' both claim fingerprint file '${file}' — a file has one claimant`,
318
351
  )
319
352
  }
320
- fileClaimants.set(file, plug.name)
353
+ fileClaimants.set(file, { name: plug.name, at: i })
321
354
  }
322
355
  }
323
356
  // A plugin must contribute at least one capability or lifecycle hook
@@ -463,6 +496,8 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
463
496
  `(got "${wild}") — list explicit env var names instead`,
464
497
  )
465
498
  }
499
+ for (const n of passThrough as string[])
500
+ assertShellName(n, `${where}.exec.env.passThrough`)
466
501
  }
467
502
  const secret = (env as { secret?: unknown }).secret
468
503
  if (
@@ -489,6 +524,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
489
524
  `${where}.exec.env.define: ${JSON.stringify(k)} is not an env var name (non-empty, no '=' or NUL)`,
490
525
  )
491
526
  }
527
+ assertShellName(k, `${where}.exec.env.define`)
492
528
  if (typeof val !== 'string' || val.includes('\0')) {
493
529
  throw new UserError(`${where}.exec.env.define.${k} must be a string with no NUL`)
494
530
  }
@@ -1350,8 +1386,19 @@ function specForm(spec: string): SpecForm {
1350
1386
  * A name an environment can hold. An `=` splits at the first one, so
1351
1387
  * `define: { 'A=B': 'x' }` gave the child `A` with the value `B=x`; `''`
1352
1388
  * was dropped; a NUL failed the spawn with a hint about exit 127 (item
1353
- * 999). Refused at load, where the config is named.
1389
+ * 999). And the task runs under `sh -c`, whose dash (Linux) drops a
1390
+ * variable whose name is no shell identifier: `my.var` reached a task
1391
+ * under macOS's bash and nothing on Linux. Refused at load, where the
1392
+ * config is named.
1354
1393
  */
1394
+ function assertShellName(name: string, field: string): void {
1395
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
1396
+ throw new UserError(
1397
+ `${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`,
1398
+ )
1399
+ }
1400
+ }
1401
+
1355
1402
  function isEnvName(name: unknown): name is string {
1356
1403
  return typeof name === 'string' && name.length > 0 && !name.includes('=') && !name.includes('\0')
1357
1404
  }
@@ -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
@@ -152,7 +152,8 @@ export async function frozenProjectConfigs(
152
152
  ): Promise<ProjectConfig[]> {
153
153
  const entries = metas.map((meta) => {
154
154
  const rel = relPosix(root, meta.configPath)
155
- const entry = lock.projects[meta.name]
155
+ // Own entries only: `constructor` read Object's, "locked" at undefined (D-152).
156
+ const entry = Object.hasOwn(lock.projects, meta.name) ? lock.projects[meta.name] : undefined
156
157
  if (entry !== undefined && entry.configPath !== rel) {
157
158
  // A renamed or moved config: "has no entry for a" named an entry the
158
159
  // lock holds, under its old path (D-88).
@@ -429,7 +429,8 @@ function pmRunsMembers(script: string): boolean {
429
429
  return false
430
430
  }
431
431
 
432
- const CD = /(?:^|[\s;&|(])cd\s+("[^"]*"|'[^']*'|[^\s;&|()]+)/g
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 target = m[1]!.replace(/^(["'])(.*)\1$/, '$2')
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)
@@ -845,7 +847,7 @@ export function migrateScripts(
845
847
  const names = runnable.filter((n) => taskNameProblem(n) === null)
846
848
  if (meta !== rootMeta && !('build' in scripts)) {
847
849
  const hook = LIFECYCLE_BUILD_HOOKS.find(
848
- (h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h] as string),
850
+ (h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h]),
849
851
  )
850
852
  if (hook !== undefined) lifecycleBuilds.push([meta.name, hook, scripts[hook] as string])
851
853
  }
@@ -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