@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 +5 -5
- package/src/cache/archive.ts +117 -13
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/tar-stream.ts +8 -3
- package/src/util/index.ts +1 -0
- package/src/util/real-path.ts +12 -0
- package/src/workspace/config-cache.ts +8 -8
- package/src/workspace/migrate-scripts.ts +5 -5
- package/src/workspace/migration.ts +34 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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.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"
|
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
|
}
|
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,
|
|
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(
|
|
399
|
-
return
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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: {
|