@vzn/vx 0.0.126 → 0.0.128
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.
|
|
3
|
+
"version": "0.0.128",
|
|
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.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.128",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.128",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.128",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.128"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/archive.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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)
|
|
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
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -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-
|
|
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
|
package/src/cache/tar-stream.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -379,7 +379,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
379
379
|
const persistent = (exec as { persistent?: unknown }).persistent
|
|
380
380
|
if (persistent !== undefined) {
|
|
381
381
|
if (typeof persistent !== 'object' || persistent === null) {
|
|
382
|
-
throw new UserError(
|
|
382
|
+
throw new UserError(
|
|
383
|
+
`${where}.exec.persistent must be an object (or omitted) — \`persistent: {}\`, or \`{ readyWhen: '<regex>' }\` to wait for a line`,
|
|
384
|
+
)
|
|
383
385
|
}
|
|
384
386
|
// The one nested object that had no unknown-key check while every
|
|
385
387
|
// sibling did — and the failure it let through is the quiet kind:
|
|
@@ -451,7 +453,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
451
453
|
}
|
|
452
454
|
if (cache !== undefined) {
|
|
453
455
|
if (typeof cache !== 'object' || cache === null) {
|
|
454
|
-
throw new UserError(
|
|
456
|
+
throw new UserError(
|
|
457
|
+
`${where}.cache must be an object when present — \`cache: { inputs: { files: [...] }, outputs: { files: [...] } }\`, or no \`cache\` for a task that never caches`,
|
|
458
|
+
)
|
|
455
459
|
}
|
|
456
460
|
assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
|
|
457
461
|
const inputs = (cache as { inputs?: unknown }).inputs
|
|
@@ -697,6 +701,29 @@ export const REMOVED_FIELDS: ReadonlyMap<
|
|
|
697
701
|
],
|
|
698
702
|
])
|
|
699
703
|
|
|
704
|
+
/**
|
|
705
|
+
* Another runner's spelling of a field vx has under another name, by the
|
|
706
|
+
* level that meets it (D-37): a Turbo `outputs: [...]` or Nx `command` on
|
|
707
|
+
* a task was refused as an unknown field that named neither the field
|
|
708
|
+
* nor where vx keeps it.
|
|
709
|
+
*/
|
|
710
|
+
const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, string>>> = new Map([
|
|
711
|
+
[
|
|
712
|
+
TASK_FIELDS,
|
|
713
|
+
{
|
|
714
|
+
inputs: '`cache.inputs.files`',
|
|
715
|
+
outputs: '`cache.outputs.files`',
|
|
716
|
+
env: '`cache.inputs.env` (to key the task on a variable) or `exec.env` (to pass one)',
|
|
717
|
+
passThroughEnv: '`exec.env.passThrough`',
|
|
718
|
+
command: '`exec.command`',
|
|
719
|
+
cmd: '`exec.command`',
|
|
720
|
+
script: '`exec.command`',
|
|
721
|
+
persistent: '`exec.persistent: {}`',
|
|
722
|
+
},
|
|
723
|
+
],
|
|
724
|
+
[EXEC_FIELDS, { cmd: '`command`', script: '`command`' }],
|
|
725
|
+
])
|
|
726
|
+
|
|
700
727
|
function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: string): void {
|
|
701
728
|
// `typeof [] === 'object'`, so an array reaches here and its indices read
|
|
702
729
|
// as fields: `outputs: ['dist/**']` (Turbo's spelling) was refused as
|
|
@@ -717,10 +744,15 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
|
|
|
717
744
|
}
|
|
718
745
|
// The nearest accepted spelling first: the list says what the level
|
|
719
746
|
// takes, the hint says which one was meant.
|
|
747
|
+
const foreign = FOREIGN_FIELDS.get(allowed)?.[key]
|
|
720
748
|
const near = nearest(key, allowed)
|
|
721
749
|
throw new UserError(
|
|
722
750
|
`${where} has unknown field "${key}" (allowed: ${[...allowed].sort().join(', ')})` +
|
|
723
|
-
(
|
|
751
|
+
(foreign !== undefined
|
|
752
|
+
? ` — vx spells it ${foreign}`
|
|
753
|
+
: near === undefined
|
|
754
|
+
? ''
|
|
755
|
+
: ` — did you mean ${near}?`),
|
|
724
756
|
)
|
|
725
757
|
}
|
|
726
758
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`${
|
|
280
|
+
`${todoCount} TODO${todoCount === 1 ? '' : 's'}${todoCount > 0 ? ':' : ''}`,
|
|
266
281
|
)
|
|
267
|
-
for (const
|
|
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: {
|