@vzn/vx 0.0.127 → 0.0.129

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.127",
3
+ "version": "0.0.129",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.127",
21
- "@vzn/vx-linux-arm64": "0.0.127",
22
- "@vzn/vx-darwin-x64": "0.0.127",
23
- "@vzn/vx-darwin-arm64": "0.0.127"
20
+ "@vzn/vx-linux-x64": "0.0.129",
21
+ "@vzn/vx-linux-arm64": "0.0.129",
22
+ "@vzn/vx-darwin-x64": "0.0.129",
23
+ "@vzn/vx-darwin-arm64": "0.0.129"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -49,6 +49,79 @@ const META_ENTRY = '.vx-meta.json'
49
49
  /** Archive entry name of the always-present stdout record. */
50
50
  const STDOUT_ENTRY = 'stdout'
51
51
 
52
+ /**
53
+ * The last entry: a CRC-32 over every entry before it, each its name, a
54
+ * NUL, then its body, as 8 lowercase hex digits (v36, L-19). Nothing else
55
+ * checks an entry's BODY: tar sums its headers only, and neither zstd
56
+ * writer asks for a frame checksum, so a byte flipped in a raw zstd block
57
+ * (incompressible output: an image, a wasm, a tarball) decoded clean and
58
+ * replayed wrong bytes under a green hit — 1,141 of 1,141 flips in a
59
+ * random 8 KB body went unseen (probed). A CRC catches that transit or
60
+ * storage damage; it is no defense against a store that forges artifacts,
61
+ * which keys cannot tell apart from honest ones either.
62
+ */
63
+ const SUM_ENTRY = '.vx-sum'
64
+ const SUM_SIZE = 8
65
+
66
+ /** The running CRC-32 over entries' names and bodies, as `SUM_ENTRY` defines it. */
67
+ class EntrySum {
68
+ private crc = 0
69
+ entry(name: string): void {
70
+ this.crc = Bun.hash.crc32(new TextEncoder().encode(`${name}\0`), this.crc)
71
+ }
72
+ add(chunk: Uint8Array): void {
73
+ this.crc = Bun.hash.crc32(chunk, this.crc)
74
+ }
75
+ async *through(body: AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array> {
76
+ for await (const chunk of body) {
77
+ this.add(chunk)
78
+ yield chunk
79
+ }
80
+ }
81
+ hex(): string {
82
+ return (this.crc >>> 0).toString(16).padStart(SUM_SIZE, '0')
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The reader's half: every regular entry is hashed as it is consumed, and
88
+ * `check` is handed the sum entry's body. An archive whose sum is absent,
89
+ * wrong, or followed by another entry is refused before anything it held
90
+ * is committed.
91
+ */
92
+ class SumCheck {
93
+ private readonly sum = new EntrySum()
94
+ private seen: boolean = false
95
+ /** The entry's body, hashed as the caller reads it; null for the sum entry itself (checked here). */
96
+ async body(
97
+ name: string,
98
+ body: AsyncIterable<Uint8Array>,
99
+ ): Promise<AsyncIterable<Uint8Array> | null> {
100
+ if (this.seen) throw new TarFormatError(`entry ${name} follows the artifact's checksum`)
101
+ if (name === SUM_ENTRY) {
102
+ const got = await textOf(body)
103
+ if (got !== this.sum.hex()) {
104
+ throw new TarFormatError(
105
+ `artifact content does not match its checksum (${got}, read ${this.sum.hex()})`,
106
+ )
107
+ }
108
+ this.seen = true
109
+ return null
110
+ }
111
+ this.sum.entry(name)
112
+ return this.sum.through(body)
113
+ }
114
+ done(): void {
115
+ if (!this.seen) throw new TarFormatError('artifact carries no checksum')
116
+ }
117
+ }
118
+
119
+ const drain = async (body: AsyncIterable<Uint8Array>): Promise<void> => {
120
+ for await (const _ of body) {
121
+ // read through the checksum
122
+ }
123
+ }
124
+
52
125
  const WIN32 = process.platform === 'win32'
53
126
  // A Windows link target may be spelled with either separator.
54
127
  const LINK_SEP = WIN32 ? /[\\/]/ : '/'
@@ -190,12 +263,36 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
190
263
  })),
191
264
  { name: META_ENTRY, size: metaBytes.byteLength, body: metaBytes },
192
265
  ]
193
- return { inputs, size: tarSize(inputs) }
266
+ return { inputs, size: tarSize([...inputs, sumInput('0'.repeat(SUM_SIZE))]) }
194
267
  }
195
268
 
196
269
  /** The tar as a stream: each output is read from disk as it is written. */
197
270
  export function packArtifactStream(plan: ArtifactPlan): ReadableStream<Uint8Array> {
198
- return streamOf(tarPack(plan.inputs))
271
+ return streamOf(tarPack(summed(plan.inputs)))
272
+ }
273
+
274
+ const sumInput = (hex: string): TarInput => ({ name: SUM_ENTRY, size: SUM_SIZE, body: hex })
275
+
276
+ /**
277
+ * The inputs with each body hashed as `tarPack` reads it, then the sum
278
+ * entry: `tarPack` drains one input before it asks for the next, so the
279
+ * sum is whole when it is asked for.
280
+ */
281
+ async function* summed(inputs: readonly TarInput[]): AsyncGenerator<TarInput> {
282
+ const sum = new EntrySum()
283
+ for (const i of inputs) {
284
+ sum.entry(i.name)
285
+ if (i.body instanceof Blob) {
286
+ yield { ...i, body: sum.through(i.body.stream()) }
287
+ } else if (typeof i.body === 'string' || i.body instanceof Uint8Array) {
288
+ const bytes = typeof i.body === 'string' ? new TextEncoder().encode(i.body) : i.body
289
+ sum.add(bytes)
290
+ yield { ...i, body: bytes }
291
+ } else {
292
+ yield { ...i, body: sum.through(i.body) }
293
+ }
294
+ }
295
+ yield sumInput(sum.hex())
199
296
  }
200
297
 
201
298
  /**
@@ -212,7 +309,7 @@ export async function packArtifactBytes(plan: ArtifactPlan): Promise<Uint8Array>
212
309
  )
213
310
  const out = new Uint8Array(plan.size)
214
311
  let off = 0
215
- for await (const chunk of tarPack(inputs)) {
312
+ for await (const chunk of tarPack(summed(inputs))) {
216
313
  out.set(chunk, off)
217
314
  off += chunk.byteLength
218
315
  }
@@ -256,10 +353,13 @@ export async function scanArtifact(tar: ReadableStream<Uint8Array>): Promise<{
256
353
  let exec: ExecUsage | undefined
257
354
  let key: string | undefined
258
355
  let stdout: string | null = null
356
+ const check = new SumCheck()
259
357
  for await (const e of tarEntries(tar)) {
260
358
  if (e.type !== '0') continue
359
+ const body = await check.body(e.name, e.body)
360
+ if (body === null) continue
261
361
  if (e.name === META_ENTRY) {
262
- const parsed = JSON.parse(await textOf(e.body)) as MetaFile
362
+ const parsed = JSON.parse(await textOf(body)) as MetaFile
263
363
  meta = parsed.files ?? {}
264
364
  // The ingest side is the untrusted boundary: a foreign sidecar's
265
365
  // usage is taken only when it is a plain non-negative number.
@@ -268,9 +368,11 @@ export async function scanArtifact(tar: ReadableStream<Uint8Array>): Promise<{
268
368
  continue
269
369
  }
270
370
  assertSafeName(e.name)
271
- if (e.name === STDOUT_ENTRY) stdout = await textOf(e.body)
371
+ if (e.name === STDOUT_ENTRY) stdout = await textOf(body)
372
+ else await drain(body)
272
373
  seen.push({ name: e.name, size: e.size, mtimeMs: e.mtimeMs })
273
374
  }
375
+ check.done()
274
376
  return {
275
377
  entries: seen.map((s) => {
276
378
  const m = meta[s.name]
@@ -322,26 +424,28 @@ export async function extractArtifactStream(
322
424
  const headerMtime = new Map<string, number>()
323
425
  let meta: MetaFile['files'] = {}
324
426
  try {
427
+ const check = new SumCheck()
325
428
  for await (const e of tarEntries(tar)) {
326
429
  if (e.type !== '0') continue
430
+ const body = await check.body(e.name, e.body)
431
+ if (body === null) continue
327
432
  if (e.name === META_ENTRY) {
328
- meta = (JSON.parse(await textOf(e.body)) as MetaFile).files ?? {}
433
+ meta = (JSON.parse(await textOf(body)) as MetaFile).files ?? {}
329
434
  continue
330
435
  }
331
436
  assertSafeName(e.name)
332
437
  const dest = x.destFor(e.name)
333
- if (dest === null || dest.rel.length === 0) continue
438
+ if (dest === null || dest.rel.length === 0) {
439
+ await drain(body)
440
+ continue
441
+ }
334
442
  const target = path.join(dest.base, dest.rel)
335
443
  await x.assertContained(dest.base, target, e.name)
336
444
  provided.add(e.name)
337
445
  headerMtime.set(e.name, e.mtimeMs)
338
- await x.stage(
339
- e.name,
340
- dest.base,
341
- target,
342
- e.size <= SMALL_ENTRY ? await bytesOf(e.body) : e.body,
343
- )
446
+ await x.stage(e.name, dest.base, target, e.size <= SMALL_ENTRY ? await bytesOf(body) : body)
344
447
  }
448
+ check.done()
345
449
  verify?.(provided)
346
450
  // The sidecar's stat is the truth, a mode of 000 and an mtime at or
347
451
  // before the epoch included (`SOURCE_DATE_EPOCH=0`). Without one, a
@@ -87,7 +87,7 @@ import type { CacheKeyInput } from './layer.js'
87
87
  // never folded and saved what it built. The fix withholds that link unless
88
88
  // the key answers for the package, but an entry saved before it still hits
89
89
  // after the sibling changes (probed: the old output replayed).
90
- export const CACHE_VERSION = 'vx-cache-v35'
90
+ export const CACHE_VERSION = 'vx-cache-v36'
91
91
 
92
92
  /**
93
93
  * Fold one task's key inputs into its 16-hex cache key. `hashFile` answers
@@ -246,7 +246,8 @@ export interface TarInput {
246
246
  mode?: number
247
247
  /** Seconds since epoch; the sidecar carries the millisecond value. */
248
248
  mtime?: number
249
- body: Blob | Uint8Array | string
249
+ /** An iterable is read once, in order, as the entry is written (`size` is its length). */
250
+ body: Blob | Uint8Array | string | AsyncIterable<Uint8Array>
250
251
  }
251
252
 
252
253
  const encoder = new TextEncoder()
@@ -326,6 +327,9 @@ function needsPax(name: string): boolean {
326
327
  return bytes.byteLength > 100 && splitForUstar(bytes) === null
327
328
  }
328
329
 
330
+ const isChunks = (b: TarInput['body']): b is AsyncIterable<Uint8Array> =>
331
+ typeof b === 'object' && Symbol.asyncIterator in b
332
+
329
333
  /** The exact number of bytes `tarPack` writes for these inputs. */
330
334
  export function tarSize(inputs: readonly TarInput[]): number {
331
335
  let size = BLOCK * 2
@@ -360,9 +364,10 @@ export async function* tarPack(
360
364
  headerName = encoder.encode(input.name).subarray(0, 100)
361
365
  }
362
366
  yield header(headerName, input.size, '0', mode, mtime)
363
- if (input.body instanceof Blob) {
367
+ if (input.body instanceof Blob || isChunks(input.body)) {
364
368
  let n = 0
365
- for await (const chunk of input.body.stream()) {
369
+ const chunks = input.body instanceof Blob ? input.body.stream() : input.body
370
+ for await (const chunk of chunks) {
366
371
  n += chunk.byteLength
367
372
  yield chunk
368
373
  }
package/src/util/index.ts CHANGED
@@ -51,4 +51,5 @@ export { CORE_VERBS, MOVED_VERBS } from './verbs.js'
51
51
  export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.js'
52
52
  export { executablePath, shellArgv } from './which.js'
53
53
  export { procfsIsOwn } from './procfs.js'
54
+ export { realPath } from './real-path.js'
54
55
  export { maskedCommand, maskedEmitter, MASKED, secretMask, type SecretMask } from './secret-mask.js'
@@ -0,0 +1,12 @@
1
+ import { realpathSync } from 'node:fs'
2
+
3
+ /**
4
+ * A path's canonical spelling. On Windows `realpathSync` keeps an 8.3
5
+ * short name (`C:\Users\RUNNER~1\…`) where git, `fs.promises.realpath`
6
+ * and every other reader say `runneradmin`, so one file had two
7
+ * spellings and a key or a comparison that folds the path split on
8
+ * them (O-16). The native call asks the OS for the final path.
9
+ */
10
+ export function realPath(p: string): string {
11
+ return realpathSync.native(p)
12
+ }
@@ -28,9 +28,9 @@
28
28
  // `vx lock` both go through `JSON.stringify` — so a cached config derives
29
29
  // the same cache key as a live evaluation of the same bytes.
30
30
 
31
- import { lstatSync, readFileSync, realpathSync, statSync } from 'node:fs'
31
+ import { lstatSync, readFileSync, statSync } from 'node:fs'
32
32
  import path from 'node:path'
33
- import { xxh3 } from '../util/index.js'
33
+ import { realPath, xxh3 } from '../util/index.js'
34
34
  import { VERSION } from '../version.js'
35
35
 
36
36
  /** Bump when the key derivation or the stored shape changes. */
@@ -395,8 +395,8 @@ const EXPLICIT_EXT = /\.(?:m?[jt]s|cjs|cts)$/
395
395
  * per run (`read-once.unsafe.test.ts`).
396
396
  */
397
397
  function realDirOf(file: string): string {
398
- if (lstatSync(file).isSymbolicLink()) return path.dirname(realpathSync(file))
399
- return realpathSync(path.dirname(file))
398
+ if (lstatSync(file).isSymbolicLink()) return path.dirname(realPath(file))
399
+ return realPath(path.dirname(file))
400
400
  }
401
401
 
402
402
  /** Each workspace root's real path, asked once per process. */
@@ -418,7 +418,7 @@ function reachedThroughNoLink(file: string, realDir: string, root: string | unde
418
418
  if (root === undefined) return true
419
419
  let realRoot = realRoots.get(root)
420
420
  if (realRoot === undefined) {
421
- realRoot = realpathSync(root)
421
+ realRoot = realPath(root)
422
422
  realRoots.set(root, realRoot)
423
423
  }
424
424
  return path.relative(realRoot, realDir) === path.relative(root, dir)
@@ -427,7 +427,7 @@ function reachedThroughNoLink(file: string, realDir: string, root: string | unde
427
427
  /** A regular file reached through no symlink: the path IS the file. */
428
428
  function isCanonicalFile(file: string): boolean {
429
429
  try {
430
- return realpathSync(file) === file && statSync(file).isFile()
430
+ return realPath(file) === file && statSync(file).isFile()
431
431
  } catch {
432
432
  return false
433
433
  }
@@ -500,7 +500,7 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
500
500
  // workspace under /var vs /private/var), and the key folds the
501
501
  // path — a spelling that drifts between runs is a spurious miss
502
502
  // and a duplicated memo row.
503
- resolved = realpathSync(Bun.resolveSync(spec, dir))
503
+ resolved = realPath(Bun.resolveSync(spec, dir))
504
504
  } catch {
505
505
  return null
506
506
  }
@@ -574,7 +574,7 @@ export async function configImports(configPath: string): Promise<string[]> {
574
574
  let resolved: string
575
575
  try {
576
576
  dir ??= realDirOf(file)
577
- resolved = realpathSync(Bun.resolveSync(spec, dir))
577
+ resolved = realPath(Bun.resolveSync(spec, dir))
578
578
  } catch {
579
579
  continue
580
580
  }
@@ -37,6 +37,7 @@ import {
37
37
  type MigrationPlan,
38
38
  PERSISTENT_TASK_NAMES,
39
39
  PERSISTENT_TODO,
40
+ CACHE_TODO,
40
41
  } from './migration.js'
41
42
 
42
43
  // `lint` is not here: a linter reads sources, and an edge to `build`
@@ -187,10 +188,6 @@ function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
187
188
  return raw as Record<string, unknown>
188
189
  }
189
190
 
190
- /** The cache block the task that builds should declare, as a TODO on it. */
191
- const CACHE_TODO =
192
- "cache: add `cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task"
193
-
194
191
  /**
195
192
  * A `build` that only delegates (`build: pnpm run compile`) is a group over
196
193
  * its target, and the `^build` edge every `build` carries belongs on the
@@ -330,7 +327,10 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
330
327
  }
331
328
  return {
332
329
  headerNotes: [
333
- 'each script became a task with its command verbatim; caching needs declared inputs and outputs, so no task got a cache block — `build`, or the script it delegates to, carries a TODO showing the one to add',
330
+ // Where to add one is the TODO's to say, when there is one: an Nx repo
331
+ // whose only script is `codegen` read "`build` carries a TODO" over
332
+ // "0 TODOs" (the first-five-minutes walk, 2026-09-28).
333
+ 'each script became a task with its command verbatim; caching needs declared inputs and outputs, so no task got a cache block',
334
334
  ],
335
335
  projects,
336
336
  extraFiles: [],
@@ -67,6 +67,10 @@ export function foldScriptHooks(
67
67
  return `vx_script() {\n${parts.join(' && ')}\n}\nvx_script`
68
68
  }
69
69
 
70
+ /** The cache block the task that builds should declare, as a TODO on it. */
71
+ export const CACHE_TODO =
72
+ "cache: add `cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task"
73
+
70
74
  /** The one wording every mapper emits for a task it made persistent. */
71
75
  export const PERSISTENT_TODO =
72
76
  'persistent task — set persistent.readyWhen (regex matched against output) so ' +
@@ -219,12 +223,23 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
219
223
  for (const f of replaced) await unlink(f)
220
224
  }
221
225
 
222
- const todoList: string[] = []
226
+ // One reason, its tasks: on a Turbo template the same two-line cache TODO
227
+ // printed five times and the persistent one eight (the first-five-minutes
228
+ // walk, 2026-09-28), and the list read as noise.
229
+ const todos = new Map<string, string[]>()
230
+ let todoCount = 0
223
231
  let clean = 0
232
+ let cached = false
224
233
  for (const p of plan.projects) {
225
234
  for (const t of p.tasks) {
226
235
  if (t.todos.length === 0 && t.task !== null) clean++
227
- for (const reason of t.todos) todoList.push(`${p.name}#${t.name}: ${reason}`)
236
+ if (t.task !== null && 'cache' in t.task) cached = true
237
+ for (const reason of t.todos) {
238
+ todoCount++
239
+ const ids = todos.get(reason) ?? []
240
+ ids.push(`${p.name}#${t.name}`)
241
+ todos.set(reason, ids)
242
+ }
228
243
  }
229
244
  }
230
245
  const report: string[] = []
@@ -262,9 +277,16 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
262
277
  report.push(
263
278
  '',
264
279
  `${clean} task${clean === 1 ? '' : 's'} migrated clean, ` +
265
- `${todoList.length} TODO${todoList.length === 1 ? '' : 's'}${todoList.length > 0 ? ':' : ''}`,
280
+ `${todoCount} TODO${todoCount === 1 ? '' : 's'}${todoCount > 0 ? ':' : ''}`,
266
281
  )
267
- for (const line of todoList) report.push(` ${line}`)
282
+ for (const [reason, ids] of todos) {
283
+ if (ids.length === 1) report.push(` ${ids[0]}: ${reason}`)
284
+ else {
285
+ const shown = ids.slice(0, TODO_IDS_SHOWN).join(', ')
286
+ const more = ids.length - TODO_IDS_SHOWN
287
+ report.push(` ${shown}${more > 0 ? ` and ${more} more` : ''}:`, ` ${reason}`)
288
+ }
289
+ }
268
290
  report.push(...plan.notes)
269
291
  report.push(dry ? 'files (dry run, nothing written):' : 'files written:')
270
292
  for (const f of files) report.push(` ${f.relPath}`)
@@ -277,11 +299,19 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
277
299
  plan.projects.flatMap((p) => p.tasks.map((t) => t.name)).find((n) => n === 'build') ??
278
300
  plan.projects[0]?.tasks[0]?.name ??
279
301
  'build'
302
+ // A run of tasks none of which caches is never a hit, and a first try
303
+ // that runs twice to see the cache work saw it run twice.
304
+ if (!cached && todos.has(CACHE_TODO)) {
305
+ report.push('', 'no task caches yet: add the cache block a TODO shows, and a second run hits')
306
+ }
280
307
  report.push('', `next: vx run ${firstTask} --all`)
281
308
  process.stdout.write(`${report.join('\n')}\n`)
282
309
  return 0
283
310
  }
284
311
 
312
+ /** Ids named per shared TODO; the files carry each one (sveltejs/kit: 92 persistent tasks). */
313
+ const TODO_IDS_SHOWN = 5
314
+
285
315
  const EXAMPLE_BODY = `export default {
286
316
  tasks: {
287
317
  build: {