@vzn/vx 0.0.369 → 0.0.371

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.369",
3
+ "version": "0.0.371",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.369",
25
- "@vzn/vx-linux-arm64": "0.0.369",
26
- "@vzn/vx-darwin-x64": "0.0.369",
27
- "@vzn/vx-darwin-arm64": "0.0.369"
24
+ "@vzn/vx-linux-x64": "0.0.371",
25
+ "@vzn/vx-linux-arm64": "0.0.371",
26
+ "@vzn/vx-darwin-x64": "0.0.371",
27
+ "@vzn/vx-darwin-arm64": "0.0.371"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -223,7 +223,16 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
223
223
  // the last entry's bytes on disk under a green run (item 886). A row
224
224
  // is current only with the inode and ctime recorded after the save
225
225
  // or restore that wrote it. The cache KEY is unchanged.
226
- export const SCHEMA_VERSION = 'v28'
226
+ // v29: entries.stdout moved to entry_stdout. SQLite rewrites a whole
227
+ // record on UPDATE, so the run-end `accessed_at` bump rewrote each
228
+ // hit's stored stdout (up to 16 MB) with its overflow pages: 200
229
+ // hits of 1 MB cost the close 125-150 ms. The cache KEY is
230
+ // unchanged.
231
+ export const SCHEMA_VERSION = 'v29'
232
+
233
+ /** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
234
+ const SELECT_ENTRY =
235
+ "SELECT e.*, COALESCE(s.stdout, '') AS stdout FROM entries e LEFT JOIN entry_stdout s ON s.hash = e.hash"
227
236
 
228
237
  /** A schema version's number (`v28` → 28); one that is not `v<n>` is older than any. */
229
238
  function schemaOrdinal(version: string): number {
@@ -439,6 +448,8 @@ export class Cache implements CacheLayer {
439
448
  private readonly insertEntry: ReturnType<Database['prepare']>
440
449
  private readonly deleteEntryRow: ReturnType<Database['prepare']>
441
450
  private readonly selectEntry: ReturnType<Database['prepare']>
451
+ private readonly upsertStdout: ReturnType<Database['prepare']>
452
+ private readonly deleteStdout: ReturnType<Database['prepare']>
442
453
  private readonly entryExists: ReturnType<Database['prepare']>
443
454
  private readonly bumpAccessed: ReturnType<Database['prepare']>
444
455
  private readonly touched = new Set<string>()
@@ -675,7 +686,7 @@ export class Cache implements CacheLayer {
675
686
  }
676
687
  if (found === SCHEMA_VERSION) return null
677
688
  this.db.exec(
678
- 'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs;',
689
+ 'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs; DROP TABLE IF EXISTS entry_stdout;',
679
690
  )
680
691
  this.db
681
692
  .prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
@@ -697,10 +708,9 @@ export class Cache implements CacheLayer {
697
708
 
698
709
  this.deleteEntryRow = this.db.prepare('DELETE FROM entries WHERE hash = ?')
699
710
  this.insertEntry = this.db.prepare(`
700
- INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, stdout, created_at, accessed_at, cpu_ms, peak_rss_bytes)
701
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
711
+ INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, created_at, accessed_at, cpu_ms, peak_rss_bytes)
712
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
702
713
  ON CONFLICT(hash) DO UPDATE SET
703
- stdout = excluded.stdout,
704
714
  project = excluded.project,
705
715
  task = excluded.task,
706
716
  command = excluded.command,
@@ -711,12 +721,15 @@ export class Cache implements CacheLayer {
711
721
  cpu_ms = excluded.cpu_ms,
712
722
  peak_rss_bytes = excluded.peak_rss_bytes
713
723
  `)
714
- this.selectEntry = this.db.prepare('SELECT * FROM entries WHERE hash = ?')
715
- // `has` asks only whether the row is there: the whole row, stdout
716
- // included, was built and dropped once per task of a `--dry` plan. A
724
+ this.upsertStdout = this.db.prepare(
725
+ 'INSERT INTO entry_stdout(hash, stdout) VALUES (?, ?) ON CONFLICT(hash) DO UPDATE SET stdout = excluded.stdout',
726
+ )
727
+ this.deleteStdout = this.db.prepare('DELETE FROM entry_stdout WHERE hash = ?')
728
+ this.selectEntry = this.db.prepare(`${SELECT_ENTRY} WHERE e.hash = ?`)
729
+ // `has` asks only whether the row is there, with no stdout joined. A
717
730
  // column off the index, so the table row is still read and a corrupt
718
731
  // table refuses here as it does on `get` (`SELECT 1` answers from the
719
- // index alone); stdout's overflow pages are not.
732
+ // index alone).
720
733
  this.entryExists = this.db.prepare('SELECT exit_code FROM entries WHERE hash = ?')
721
734
  this.bumpAccessed = this.db.prepare('UPDATE entries SET accessed_at = ? WHERE hash = ?')
722
735
  // INSERT OR IGNORE: re-saving the same hash (idempotent ingest /
@@ -961,7 +974,7 @@ export class Cache implements CacheLayer {
961
974
  const placeholders = chunk.map(() => '?').join(',')
962
975
  rows.push(
963
976
  ...(this.db
964
- .query(`SELECT * FROM entries WHERE hash IN (${placeholders})`)
977
+ .query(`${SELECT_ENTRY} WHERE e.hash IN (${placeholders})`)
965
978
  .all(...(chunk as readonly SQLQueryBindings[])) as EntryRow[]),
966
979
  )
967
980
  }
@@ -1580,6 +1593,8 @@ export class Cache implements CacheLayer {
1580
1593
  // (not the per-run path) so a warm all-cache-hit run — which never
1581
1594
  // saves — writes none of them.
1582
1595
  const insertEntry = this.insertEntry
1596
+ const upsertStdout = this.upsertStdout
1597
+ const deleteStdout = this.deleteStdout
1583
1598
  const outputs = this.outputs
1584
1599
  const insertEntryInput = this.insertEntryInput
1585
1600
  const inputComponents = meta.inputComponents
@@ -1629,7 +1644,6 @@ export class Cache implements CacheLayer {
1629
1644
  0,
1630
1645
  meta.durationMs,
1631
1646
  totalBytes,
1632
- stdoutText,
1633
1647
  now,
1634
1648
  now,
1635
1649
  // From the artifact, on both paths: a save indexes what it just
@@ -1637,6 +1651,8 @@ export class Cache implements CacheLayer {
1637
1651
  scanned.exec?.cpuMs ?? null,
1638
1652
  scanned.exec?.peakRssBytes ?? null,
1639
1653
  )
1654
+ if (stdoutText === '') deleteStdout.run(hash)
1655
+ else upsertStdout.run(hash, stdoutText)
1640
1656
  outputs.replaceFileRows(hash, outputFileRows)
1641
1657
  // INSERT OR IGNORE: identical inputs derive this same hash, so a
1642
1658
  // re-save's rows are identical — keep the first set, skip the rest.
@@ -18,10 +18,8 @@ export function createTables(db: Database): void {
18
18
  `)
19
19
 
20
20
  db.exec(`
21
- -- The queryable index: command, exit_code, duration, size,
22
- -- timestamps, and stdout, which the <hash>.tar.zst artifact also
23
- -- carries so it survives a remote round trip; a local hit
24
- -- replays it from here.
21
+ -- The queryable index: command, exit_code, duration, size and
22
+ -- timestamps; stdout in entry_stdout below.
25
23
  CREATE TABLE IF NOT EXISTS entries (
26
24
  hash TEXT PRIMARY KEY,
27
25
  project TEXT NOT NULL,
@@ -30,13 +28,21 @@ export function createTables(db: Database): void {
30
28
  exit_code INTEGER NOT NULL,
31
29
  duration_ms INTEGER NOT NULL,
32
30
  size_bytes INTEGER NOT NULL,
33
- stdout TEXT NOT NULL DEFAULT '',
34
31
  created_at INTEGER NOT NULL,
35
32
  accessed_at INTEGER NOT NULL,
36
33
  -- v26: the producing execution's usage, from the artifact's sidecar.
37
34
  cpu_ms INTEGER,
38
35
  peak_rss_bytes INTEGER
39
36
  );
37
+ -- v29: an entry's stdout, which the <hash>.tar.zst artifact also
38
+ -- carries so it survives a remote round trip; a local hit replays it
39
+ -- from here. Its own table: an UPDATE rewrites a whole record, and the
40
+ -- accessed_at bump rewrote up to 16 MB a hit. No row: empty stdout.
41
+ CREATE TABLE IF NOT EXISTS entry_stdout (
42
+ hash TEXT PRIMARY KEY,
43
+ stdout TEXT NOT NULL,
44
+ FOREIGN KEY (hash) REFERENCES entries(hash) ON DELETE CASCADE
45
+ );
40
46
  CREATE TABLE IF NOT EXISTS runs (
41
47
  id INTEGER PRIMARY KEY AUTOINCREMENT,
42
48
  -- '' when the outcome derived no cache key (skipped / persistent).
package/src/cli/select.ts CHANGED
@@ -26,7 +26,12 @@ import type { ProjectConfig } from '../config.js'
26
26
  import type { ProjectEntry } from '../workspace/index.js'
27
27
  import { parseDependencySpec } from '../graph/index.js'
28
28
  import { maskedLine, nearest, UserError } from '../util/index.js'
29
- import { claimedAffected, fingerprintClaims, gitOfDiscovery } from '../orchestrator/index.js'
29
+ import {
30
+ claimedAffected,
31
+ fingerprintClaims,
32
+ gitOfDiscovery,
33
+ keepDiscoveryGraph,
34
+ } from '../orchestrator/index.js'
30
35
  import {
31
36
  type CliLoadOptions,
32
37
  discoverCliProjects,
@@ -251,6 +256,9 @@ export async function resolveFilters(
251
256
  }
252
257
  }
253
258
  const graph = buildPackageGraph(projects, edges)
259
+ // The graph a run reusing this discovery builds is this one when no task
260
+ // edge went into it.
261
+ if (edges === undefined) keepDiscoveryGraph(projects, graph)
254
262
 
255
263
  // Resolve every `[<since>]` filter against git before the pure
256
264
  // applyFilters pass runs. One spawn per distinct ref — usually
@@ -88,6 +88,7 @@ import {
88
88
  srtDefaultWritePaths,
89
89
  } from './sandbox-deny-scan.js'
90
90
  import {
91
+ hiddenReadsOutside,
91
92
  parseStraceViolations,
92
93
  refusedConnections,
93
94
  refusedWrites,
@@ -1820,6 +1821,16 @@ async function runSandboxedOnce(
1820
1821
  })
1821
1822
  }
1822
1823
 
1824
+ if (exitCode !== 0) {
1825
+ const hidden = hiddenReadsOutside(recorded, {
1826
+ within: args.reportWithin,
1827
+ linked: args.reportLinked,
1828
+ config: args.config,
1829
+ skip: [taskTmpRoot()],
1830
+ })
1831
+ if (hidden.length > 0) violations.push(hiddenReadsHint(hidden, args.reportWithin))
1832
+ }
1833
+
1823
1834
  try {
1824
1835
  SandboxManager.cleanupAfterCommand()
1825
1836
  } catch {
@@ -1867,6 +1878,19 @@ function outsideWritesHint(paths: readonly string[]): SandboxViolation {
1867
1878
  }
1868
1879
  }
1869
1880
 
1881
+ function hiddenReadsHint(paths: readonly string[], within: string): SandboxViolation {
1882
+ const shown = paths.slice(0, 5).join(', ')
1883
+ const more = paths.length > 5 ? ` and ${paths.length - 5} more` : ''
1884
+ return {
1885
+ timestamp: new Date(),
1886
+ hint: true,
1887
+ line:
1888
+ `vx: the sandbox hid paths outside the project that exist on this machine, which are ` +
1889
+ `not reported as violations: ${shown}${more}. If the task reads one, grant it, e.g. ` +
1890
+ `\`allow: { read: ['${path.relative(toRealPath(within), paths[0]!)}'] }\`.`,
1891
+ }
1892
+ }
1893
+
1870
1894
  /**
1871
1895
  * Is `dir` inside a path the sandbox granted for reading? bwrap binds and
1872
1896
  * seatbelt rules are both prefix-based, so a grant covers the subtree.
@@ -4,6 +4,7 @@
4
4
  // task's owner can act on — inside the project, minus the loopback denial
5
5
  // no grant can avoid, minus what the task chose to ignore.
6
6
 
7
+ import { statSync } from 'node:fs'
7
8
  import path from 'node:path'
8
9
  import { absolutize, atOrUnder, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
9
10
  import { bindableReads } from './sandbox-binds.js'
@@ -500,6 +501,44 @@ export function refusedWritesOutside(
500
501
  return [...paths]
501
502
  }
502
503
 
504
+ /**
505
+ * The paths outside the project a read was denied that exist on this
506
+ * machine, files first: the reads `reportableViolations` drops at the
507
+ * wall. A task that failed on one said only what its tool says for a
508
+ * missing file — `tsc` extending a root `tsconfig.base.json` read "File
509
+ * not found" — and nothing named the sandbox (2026-10-02). Never a
510
+ * violation — a failed task's hint (`runSandboxed`). What a process walks
511
+ * through to reach its cwd (the project's ancestors) and what `skip` names
512
+ * are left out, as is a path that does not exist on the host: that miss
513
+ * is the tool's own.
514
+ */
515
+ export function hiddenReadsOutside(
516
+ violations: readonly SandboxViolation[],
517
+ opts: {
518
+ within: string
519
+ linked?: readonly string[]
520
+ config: ResolvedSandboxConfig
521
+ skip: readonly string[]
522
+ },
523
+ ): string[] {
524
+ const reported = reportedWithin(opts.within, opts.linked ?? [])
525
+ const home = toRealPath(opts.within)
526
+ const skip = opts.skip.map(toRealPath)
527
+ const found = new Map<string, boolean>()
528
+ for (const v of filterIgnored(describe(violations), opts.config.ignore)) {
529
+ const p = v.path
530
+ if (p === undefined || v.hint === true || !v.ignorable?.includes('read') || reported(v))
531
+ continue
532
+ if (found.has(p) || atOrUnder(home, p) || skip.some((s) => atOrUnder(p, s))) continue
533
+ try {
534
+ found.set(p, statSync(p).isDirectory())
535
+ } catch {
536
+ // Not on the host either: the tool's own miss.
537
+ }
538
+ }
539
+ return [...found].sort((a, b) => Number(a[1]) - Number(b[1])).map(([p]) => p)
540
+ }
541
+
503
542
  /** Every record described: a seatbelt one parsed, a Linux one as produced. */
504
543
  function describe(violations: readonly SandboxViolation[]): SandboxViolation[] {
505
544
  return violations.map((v) =>
@@ -6,6 +6,7 @@ export { prepareRun, type PreparedRun } from './prepare.js'
6
6
  export {
7
7
  discoverProjects,
8
8
  gitOfDiscovery,
9
+ keepDiscoveryGraph,
9
10
  loadProjects,
10
11
  loadResolvedProjects,
11
12
  loadWorkspacePlugins,
@@ -57,6 +57,7 @@ import {
57
57
  import {
58
58
  discoverProjects,
59
59
  gitOfDiscovery,
60
+ graphOfDiscovery,
60
61
  loadProjects,
61
62
  loadWorkspacePlugins,
62
63
  type LoadedProjects,
@@ -223,7 +224,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
223
224
  // those configs are evaluated. Side effect, deliberate and
224
225
  // Turbo-like: a broken config in an unrelated package no longer
225
226
  // fails a scoped run — it surfaces when that package enters scope.
226
- const packageGraph = buildPackageGraph(projectMetas)
227
+ const packageGraph =
228
+ (reused !== undefined ? graphOfDiscovery(reused.projects) : undefined) ??
229
+ buildPackageGraph(projectMetas)
227
230
  // Seeds: explicit scope, plus anchored pkg#task targets (which bypass
228
231
  // scope by design). With no explicit scope, bare task names fan out
229
232
  // across the whole workspace — but when EVERY spec is anchored, the
@@ -67,6 +67,21 @@ export function gitOfDiscovery(projects: readonly ProjectMeta[]): LazyGitEnumera
67
67
  return discoveredGit.get(projects)
68
68
  }
69
69
 
70
+ /**
71
+ * The package graph a selection pass built over a discovery, by that
72
+ * discovery's array, when it folded no task edge in: the graph a run that
73
+ * reuses the discovery (`RunOptions.discovered`) would build again.
74
+ */
75
+ const discoveredGraph = new WeakMap<readonly ProjectMeta[], PackageGraph>()
76
+
77
+ export function graphOfDiscovery(projects: readonly ProjectMeta[]): PackageGraph | undefined {
78
+ return discoveredGraph.get(projects)
79
+ }
80
+
81
+ export function keepDiscoveryGraph(projects: readonly ProjectMeta[], graph: PackageGraph): void {
82
+ discoveredGraph.set(projects, graph)
83
+ }
84
+
70
85
  /**
71
86
  * The workspace's projects: core's discovery, then the plugin `discover`
72
87
  * stage. With no plugin declaring it, `listProjects` alone.
@@ -544,7 +544,21 @@ async function runOnBus(
544
544
  // (`signals.ts`); the handlers are removed in the finally below so
545
545
  // repeated run() calls (test suites) never stack listeners.
546
546
  const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
547
- const persistentRegistry = new Map<string, ReturnType<typeof Bun.spawn>>()
547
+ // A server that ends on its own while the graph still runs is said at
548
+ // once: its dependants were failing against it, and the end of the run
549
+ // was the first word of it (C-69). Not once the graph is done (the end
550
+ // of the run and the keep-alive wait say it) nor under a stop.
551
+ let graphDone = false
552
+ const persistentRegistry = new (class extends Map<string, ReturnType<typeof Bun.spawn>> {
553
+ override set(id: string, child: ReturnType<typeof Bun.spawn>): this {
554
+ super.set(id, child)
555
+ void child.exited.then((code) => {
556
+ if (code !== 0 && !graphDone && !stopRun.signal.aborted)
557
+ log.status(`vx: ${id} exited with code ${code} while the run went on`)
558
+ })
559
+ return this
560
+ }
561
+ })()
548
562
  // One stop for the run: an embedder's `RunOptions.signal` and a process
549
563
  // signal both abort it. The scheduler stops dispatching (it reads the
550
564
  // signal), the children are torn down, and run() returns through its own
@@ -951,6 +965,7 @@ async function runOnBus(
951
965
  // Empty when the short-circuit didn't fire → byte-identical.
952
966
  restoreTier: shortCircuit.restoreTier,
953
967
  })
968
+ graphDone = true
954
969
 
955
970
  // Which persistent children outlive the graph, and the bounded SIGTERM
956
971
  // of the rest, before the summary prints. Scoped to the real CLI
@@ -125,8 +125,11 @@ export interface FingerprintClaims {
125
125
  export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
126
126
  // Turbo's CI spelling, `[origin/main...HEAD]`, is the base alone here:
127
127
  // vx already diffs from the merge base (three dots' meaning), and the
128
- // working tree it diffs to holds HEAD (D-117). Any other range is refused.
129
- const since = /^(.+)\.\.\.HEAD$/.exec(args.since)?.[1] ?? args.since
128
+ // working tree it diffs to holds HEAD (D-117). Its two-dot `[A..HEAD]`
129
+ // diffs from A itself (G-145). Any other range is refused.
130
+ const headRange = /^(.+?)(\.{2,3})HEAD$/s.exec(args.since)
131
+ const since = headRange?.[1] ?? args.since
132
+ const fromMergeBase = headRange?.[2] !== '..'
130
133
  // The base reaches git as an argument, never through a shell, so `$(…)`
131
134
  // is opaque — but an option-like value is not: `--output=<path>` is a
132
135
  // real `git diff` option and an arbitrary file write. This is a security
@@ -155,8 +158,9 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
155
158
  // CI `--affected`), and hides your own edit when the base later landed
156
159
  // byte-identical content. Turbo and Nx both diff from the merge base;
157
160
  // when there is none (unrelated histories, a detached probe) the ref
158
- // itself is the base, as before.
159
- const base = await mergeBase(args.workspaceRoot, since)
161
+ // itself is the base, as before. Turbo's two-dot range `A..HEAD` is the
162
+ // one form that diffs from A itself.
163
+ const base = fromMergeBase ? await mergeBase(args.workspaceRoot, since) : since
160
164
 
161
165
  const [diffed, untracked] = await Promise.all([
162
166
  // `--no-renames` is crucial for project-affected detection: with
@@ -109,6 +109,7 @@ self.onmessage = async (e) => {
109
109
  const own = (o) => new Map(Reflect.ownKeys(o).map((k) => [k, Object.getOwnPropertyDescriptor(o, k)]))
110
110
  const before = watched?.map(([, o]) => own(o))
111
111
  const envBefore = watched ? { ...live } : null
112
+ const cwdBefore = watched ? globalThis.process.cwd() : null
112
113
  const same = (a, b) =>
113
114
  a !== undefined && b !== undefined && a.value === b.value && a.get === b.get && a.set === b.set &&
114
115
  a.writable === b.writable && a.enumerable === b.enumerable && a.configurable === b.configurable
@@ -122,6 +123,7 @@ self.onmessage = async (e) => {
122
123
  })
123
124
  for (const k of new Set([...Object.keys(envBefore), ...Object.keys(live)]))
124
125
  if (envBefore[k] !== live[k]) out.push('process.env.' + k)
126
+ if (globalThis.process.cwd() !== cwdBefore) out.push('process.cwd (a chdir)')
125
127
  return out
126
128
  }
127
129
  try {
@@ -137,6 +139,7 @@ self.onmessage = async (e) => {
137
139
  ok: true,
138
140
  nonJson,
139
141
  json: isObject && nonJson.length === 0 ? JSON.stringify(mod) : null,
142
+ fn: typeof mod === 'function',
140
143
  changed: changed(),
141
144
  })
142
145
  } catch (err) {
@@ -156,12 +159,16 @@ self.onmessage = async (e) => {
156
159
  }
157
160
  `
158
161
 
162
+ const FUNCTION_EXPORT = (): void => {}
163
+
159
164
  const WORKER_URL = `data:text/javascript,${encodeURIComponent(WORKER_SRC)}`
160
165
 
161
166
  interface WorkerReply {
162
167
  id: number
163
168
  ok: boolean
164
169
  json: string | null
170
+ /** The default export is a function, which JSON cannot carry back. */
171
+ fn?: boolean
165
172
  nonJson: NonJsonValue[]
166
173
  name: string
167
174
  message: string
@@ -325,7 +332,10 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
325
332
  })
326
333
  const [nonJson] = reply.nonJson
327
334
  if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
328
- return reply.json === null ? null : (JSON.parse(reply.json) as unknown)
335
+ // A function stands in for the one the config exported, so the caller's
336
+ // refusal names it as the in-process load's does.
337
+ if (reply.json === null) return reply.fn === true ? FUNCTION_EXPORT : null
338
+ return JSON.parse(reply.json) as unknown
329
339
  } finally {
330
340
  // In the `finally`, not after the await: a REJECTED evaluation — a config
331
341
  // with a typo, the common case while editing — would otherwise skip the
@@ -738,6 +738,24 @@ export function migrateScripts(
738
738
  return !LIFECYCLE.test(n) && (base === undefined || !(base in scripts))
739
739
  })
740
740
  }
741
+ // Why a root mapped nothing: `eslint .` beside a member's `lint` runs
742
+ // nothing of the workspace, and the note said it did.
743
+ const rootLeftOut = (): string => {
744
+ if (rootMeta === undefined) return 'run the workspace'
745
+ const scripts = scriptsOf(rootMeta)
746
+ const named = Object.keys(scripts).filter(
747
+ (n) => typeof scripts[n] === 'string' && scripts[n] !== '' && !LIFECYCLE.test(n),
748
+ )
749
+ const runs = runningMembers(
750
+ scripts,
751
+ rootMeta.dir,
752
+ metas.filter((m) => m !== root).map((m) => m.dir),
753
+ )
754
+ const shared = named.filter((n) => memberTasks.has(n) && !runs.has(n))
755
+ if (shared.length === 0) return 'run the workspace'
756
+ const list = `${shared.slice(0, 8).join(', ')}${shared.length > 8 ? ', …' : ''}`
757
+ return `${shared.length < named.length ? 'run the workspace or ' : ''}share a member's task name (${list})`
758
+ }
741
759
  const rootName =
742
760
  root?.name ??
743
761
  (outsideRuns
@@ -768,7 +786,7 @@ export function migrateScripts(
768
786
  // A nameless root's vx.config is skipped (vx names projects by their
769
787
  // manifest's name), so the hand-written one needs a name first (vuejs/core).
770
788
  notes.push(
771
- `${rootName} (the workspace root) not mapped: its scripts run the workspace; declare its own tasks in its vx.config by hand` +
789
+ `${rootName} (the workspace root) not mapped: its scripts ${rootLeftOut()}; declare its own tasks in its vx.config by hand` +
772
790
  (rootName === 'package.json' ? ', after giving its package.json a "name"' : ''),
773
791
  )
774
792
  }
@@ -37,12 +37,21 @@ export const WORKSPACE_CONFIG_FILENAMES = [
37
37
  'vx.workspace.cjs',
38
38
  ]
39
39
 
40
+ /** What a function default export is told; the playground says the same. */
41
+ const EXPORTED_A_FUNCTION =
42
+ 'it exports a function, and vx reads the object itself — export what the function returns'
43
+
40
44
  /** Project configs already loaded in this process, by absolute path. */
41
45
  const loadedConfigs = new Set<string>()
42
46
 
43
47
  function assertDefaultObject(mod: unknown, kind: string, configPath: string): void {
44
48
  if (!mod || typeof mod !== 'object') {
45
- throw new UserError(`${kind} config at ${configPath} did not export a default object`)
49
+ // Vite's `defineConfig(() => ({ … }))` shape: vx reads the object, and
50
+ // the bare refusal did not say what was there instead (D-110).
51
+ throw new UserError(
52
+ `${kind} config at ${configPath} did not export a default object` +
53
+ (typeof mod === 'function' ? `: ${EXPORTED_A_FUNCTION}` : ''),
54
+ )
46
55
  }
47
56
  }
48
57
 
@@ -441,6 +450,7 @@ export async function loadProjectConfigs(
441
450
  // `node:stream`), ~7 ms of a two-config warm run where every load hit.
442
451
  let builtins: BuiltinSnapshot = []
443
452
  let env: Readonly<Record<string, string | undefined>> = {}
453
+ let cwd = ''
444
454
  // More than one evaluation in flight: a change seen after one load may
445
455
  // be another's.
446
456
  let overlapping = false
@@ -484,7 +494,9 @@ export async function loadProjectConfigs(
484
494
  : await loadDefaultExport(configPath, 'Project', bytes!)
485
495
  // Before anything reads through them: a replaced `Array.prototype.includes`
486
496
  // turned the JSON-data walk's own check into "a cyclic reference".
487
- const changed = repeat ? [] : [...restoreBuiltins(builtins), ...restoreEnv(env)]
497
+ const changed = repeat
498
+ ? []
499
+ : [...restoreBuiltins(builtins), ...restoreEnv(env), ...restoreCwd(cwd)]
488
500
  // Loads overlap, so another config's change can surface after this
489
501
  // one: named here, the refusal blamed the wrong file (D-119). The round
490
502
  // finds the one that made it.
@@ -536,6 +548,7 @@ export async function loadProjectConfigs(
536
548
  if (misses.length > 0) {
537
549
  builtins = builtinSnapshot()
538
550
  env = { ...process.env }
551
+ cwd = process.cwd()
539
552
  }
540
553
  overlapping = misses.length > 1
541
554
  let next = 0
@@ -554,7 +567,10 @@ export async function loadProjectConfigs(
554
567
  if (r.closure !== undefined) learnedClosures.push(r.closure)
555
568
  }
556
569
  }
557
- const changed = misses.length > 0 ? [...restoreBuiltins(builtins), ...restoreEnv(env)] : []
570
+ const changed =
571
+ misses.length > 0
572
+ ? [...restoreBuiltins(builtins), ...restoreEnv(env), ...restoreCwd(cwd)]
573
+ : []
558
574
  if (first?.failed instanceof ChangedInRound) {
559
575
  for (const i of misses) {
560
576
  const configPath = prepared[i]!.configPath
@@ -644,9 +660,13 @@ class ChangedInRound {
644
660
  /** Without `configPath`, no config alone was found to make the change. */
645
661
  function builtinsChanged(changed: readonly string[], configPath?: string): UserError {
646
662
  const who = configPath === undefined ? 'a project config' : configPath
647
- const env = changed.some((c) => c.startsWith('process.env.'))
648
- ? '; a task gets an env var through `exec.env.define` or `passThrough`'
649
- : ''
663
+ const env =
664
+ (changed.some((c) => c.startsWith('process.env.'))
665
+ ? '; a task gets an env var through `exec.env.define` or `passThrough`'
666
+ : '') +
667
+ (changed.some((c) => c.startsWith('process.cwd'))
668
+ ? '; a task runs in its project directory, and `cd <dir> && …` in `exec.command` moves it'
669
+ : '')
650
670
  return new UserError(
651
671
  `${who} changed ${changed.join(', ')} while it was evaluated — a config must not change the built-ins vx runs on: other configs are read through them and cache keys are made with them${env}`,
652
672
  )
@@ -702,6 +722,20 @@ function restoreEnv(before: Readonly<Record<string, string | undefined>>): strin
702
722
  return changed
703
723
  }
704
724
 
725
+ /**
726
+ * A config's `process.chdir()` moved the whole process: every relative path
727
+ * vx resolves after it, its own and a plugin's, read from the config's
728
+ * choice (D-120). Put back, and named as the change.
729
+ */
730
+ function restoreCwd(before: string): string[] {
731
+ // Through globalThis: the playground bundles this module, and its
732
+ // browser shim carries no free process global.
733
+ const proc = globalThis.process
734
+ if (before === '' || proc.cwd() === before) return []
735
+ proc.chdir(before)
736
+ return ['process.cwd (a chdir)']
737
+ }
738
+
705
739
  function sameDescriptor(a: PropertyDescriptor, b: PropertyDescriptor): boolean {
706
740
  return (
707
741
  a.value === b.value &&