@vzn/vx-migrate 0.0.635 → 0.0.637
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/README.md +294 -42
- package/package.json +3 -3
- package/src/adopt.ts +23 -8
- package/src/manifest-graph.ts +65 -0
- package/src/manifests.ts +52 -0
- package/src/mapping-cache.ts +2 -8
- package/src/migrate-nx.ts +17 -16
- package/src/migrate-scripts.ts +1261 -0
- package/src/migrate-turbo.ts +6 -7
- package/src/migrate-vite-task.ts +5 -4
- package/src/migrate.ts +376 -139
- package/src/nx/index.ts +77 -20
- package/src/nx/nx-boundaries.ts +191 -0
- package/src/nx/nx-deps.ts +3 -3
- package/src/nx/nx-map.ts +37 -41
- package/src/nx/nx-upstream.ts +3 -2
- package/src/nx-cache/index.ts +5 -11
- package/src/package-fs.ts +152 -0
- package/src/packages.ts +806 -0
- package/src/paths.ts +2 -2
- package/src/remote-cache.ts +118 -0
- package/src/shared-outputs.ts +63 -19
- package/src/tracked-outputs.ts +54 -35
- package/src/turbo/index.ts +32 -20
- package/src/turbo/turbo-map.ts +96 -56
- package/src/turbo-cache/index.ts +14 -20
- package/src/vite-task/vite-task-map.ts +13 -12
- package/src/workspace-notes.ts +12 -6
- package/src/workspace-plugins.ts +41 -11
package/src/paths.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// cheapest moment to collapse a duplicate is before it does.
|
|
14
14
|
|
|
15
15
|
import path from 'node:path'
|
|
16
|
-
import type {
|
|
16
|
+
import type { PackageMeta } from './packages.js'
|
|
17
17
|
|
|
18
18
|
/** `to` relative to `from`, POSIX-separated — core's `relPosix`, which is not on the façade. */
|
|
19
19
|
export function relPosix(from: string, to: string): string {
|
|
@@ -30,7 +30,7 @@ export function relPosix(from: string, to: string): string {
|
|
|
30
30
|
* mappers did not. Null-prototype, so a turbo.json task or Nx target named
|
|
31
31
|
* `constructor` or `toString` finds no script rather than Object's method.
|
|
32
32
|
*/
|
|
33
|
-
export function packageScripts(meta:
|
|
33
|
+
export function packageScripts(meta: PackageMeta): Record<string, unknown> {
|
|
34
34
|
const raw = (meta.packageJson as unknown as { scripts?: unknown }).scripts
|
|
35
35
|
const scripts: Record<string, unknown> = Object.create(null)
|
|
36
36
|
if (!isRecord(raw)) return scripts
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The remote cache a Turbo or Nx repo already uses, as the repo shows it,
|
|
2
|
+
// so the workspace file a migration writes keeps sharing it: native or
|
|
3
|
+
// keep, from `vx init` or `bunx @vzn/vx-migrate` (M, 2026-10-14). Nx
|
|
4
|
+
// Cloud's wire is Nx's own, so it is named, never declared.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
/** The cache plugin each runner's remote speaks through, from this package. */
|
|
10
|
+
export const REMOTE_CACHE = { turbo: 'turboCache', nx: 'nxCache' } as const
|
|
11
|
+
|
|
12
|
+
/** The variable each runner's self-hosted remote cache is set by, as its plugin reads it. */
|
|
13
|
+
const REMOTE_CACHE_ENV = { turbo: 'TURBO_TOKEN', nx: 'NX_SELF_HOSTED_REMOTE_CACHE_SERVER' } as const
|
|
14
|
+
|
|
15
|
+
const CI_FILES = ['.gitlab-ci.yml', '.circleci/config.yml']
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where the repo shows a remote cache its runner uses, or undefined:
|
|
19
|
+
* turbo.json's enabled `remoteCache`, a `turbo link`ed `.turbo/config.json`,
|
|
20
|
+
* or a CI file that sets the runner's cache variable (a local repo holds
|
|
21
|
+
* no token; CI does).
|
|
22
|
+
*/
|
|
23
|
+
export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string | undefined {
|
|
24
|
+
if (runner === 'turbo') {
|
|
25
|
+
for (const f of ['turbo.json', 'turbo.jsonc']) {
|
|
26
|
+
let rc: unknown
|
|
27
|
+
try {
|
|
28
|
+
rc = (
|
|
29
|
+
Bun.JSONC.parse(readFileSync(path.join(root, f), 'utf8')) as { remoteCache?: unknown }
|
|
30
|
+
)?.remoteCache
|
|
31
|
+
} catch {
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
if (typeof rc === 'object' && rc !== null) {
|
|
35
|
+
// Turned off in turbo.json is off, whatever CI sets.
|
|
36
|
+
if ((rc as { enabled?: unknown }).enabled === false) return undefined
|
|
37
|
+
return `${f} names a remoteCache`
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// `turbo link` writes the team here (gitignored, on the machine that
|
|
41
|
+
// linked); turboCache() reads it, so a linked repo has a remote cache.
|
|
42
|
+
let linked: unknown
|
|
43
|
+
try {
|
|
44
|
+
linked = JSON.parse(readFileSync(path.join(root, '.turbo', 'config.json'), 'utf8'))
|
|
45
|
+
} catch {}
|
|
46
|
+
if (
|
|
47
|
+
typeof linked === 'object' &&
|
|
48
|
+
linked !== null &&
|
|
49
|
+
Object.entries(linked).some(
|
|
50
|
+
([k, v]) => /^(teamid|teamslug|token)$/i.test(k) && typeof v === 'string' && v !== '',
|
|
51
|
+
)
|
|
52
|
+
) {
|
|
53
|
+
return '.turbo/config.json links a team (turbo link)'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const variable = REMOTE_CACHE_ENV[runner]
|
|
57
|
+
// A line that sets it (`TURBO_TOKEN: …`, `TURBO_TOKEN=…`), not one that
|
|
58
|
+
// names it: a comment or a doc line counted as "sets" (J's lead).
|
|
59
|
+
const sets = new RegExp(`^(?!\\s*#).*\\b${variable}\\s*[:=]`, 'm')
|
|
60
|
+
const workflows = new Bun.Glob('.github/workflows/*.{yml,yaml}')
|
|
61
|
+
const files = [...workflows.scanSync({ cwd: root, dot: true })].sort().concat(CI_FILES)
|
|
62
|
+
for (const f of files) {
|
|
63
|
+
let text: string
|
|
64
|
+
try {
|
|
65
|
+
text = readFileSync(path.join(root, f), 'utf8')
|
|
66
|
+
} catch {
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if (sets.test(text)) return `${f} sets ${variable}`
|
|
70
|
+
}
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* nx.json names an Nx Cloud workspace (`nxCloudId`, `nxCloudAccessToken`,
|
|
76
|
+
* or the legacy `nx-cloud` runner). Its wire is Nx's own, so a user who
|
|
77
|
+
* expected their remote cache under vx heard nothing and ran cold.
|
|
78
|
+
*/
|
|
79
|
+
function usesNxCloud(root: string): boolean {
|
|
80
|
+
let json: {
|
|
81
|
+
nxCloudId?: unknown
|
|
82
|
+
nxCloudAccessToken?: unknown
|
|
83
|
+
tasksRunnerOptions?: { default?: { runner?: unknown } }
|
|
84
|
+
} | null
|
|
85
|
+
try {
|
|
86
|
+
json = Bun.JSONC.parse(readFileSync(path.join(root, 'nx.json'), 'utf8')) as typeof json
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
const set = (v: unknown) => typeof v === 'string' && v !== ''
|
|
91
|
+
const runner = json?.tasksRunnerOptions?.default?.runner
|
|
92
|
+
return (
|
|
93
|
+
set(json?.nxCloudId) ||
|
|
94
|
+
set(json?.nxCloudAccessToken) ||
|
|
95
|
+
(typeof runner === 'string' && /(^|\/)nx-cloud$/.test(runner))
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The report's line about the remote cache, or undefined: the cache plugin
|
|
101
|
+
* declared (`declared`), the one a kept file still lacks, or Nx Cloud.
|
|
102
|
+
*/
|
|
103
|
+
export function remoteCacheNote(
|
|
104
|
+
root: string,
|
|
105
|
+
runner: 'turbo' | 'nx',
|
|
106
|
+
declared: boolean,
|
|
107
|
+
): string | undefined {
|
|
108
|
+
const signal = remoteCacheSignal(root, runner)
|
|
109
|
+
const cache = REMOTE_CACHE[runner]
|
|
110
|
+
if (signal !== undefined) {
|
|
111
|
+
return declared
|
|
112
|
+
? `${cache}(): ${signal}, so vx shares that remote cache (inert where the variable is unset).`
|
|
113
|
+
: `${signal}: add ${cache}() from @vzn/vx-migrate to its plugins and vx shares that remote cache.`
|
|
114
|
+
}
|
|
115
|
+
return runner === 'nx' && usesNxCloud(root)
|
|
116
|
+
? 'nx.json connects Nx Cloud, whose cache vx cannot share: runs cache on this machine (nxCache() serves a self-hosted Nx cache).'
|
|
117
|
+
: undefined
|
|
118
|
+
}
|
package/src/shared-outputs.ts
CHANGED
|
@@ -26,13 +26,15 @@ import { isLiteralPattern, outputsOverlap, type GeneratedTask } from '@vzn/vx'
|
|
|
26
26
|
import { relPosix } from './paths.js'
|
|
27
27
|
import type { TrackedKinds } from './tracked-outputs.js'
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// it
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// the
|
|
29
|
+
// vx cleans outputs before a run and restores them on a hit, so an output
|
|
30
|
+
// glob that covers the project's own package.json or vx.config would delete
|
|
31
|
+
// it. Core refuses the config file (config-schema's `ownFilesCovered`); the
|
|
32
|
+
// manifest it no longer guards (core keys no package file, 2026-10-14), but
|
|
33
|
+
// a mapped task still must not delete it. Turbo caches it: trpc's client
|
|
34
|
+
// build lists `package.json` (it rewrites `exports`), and the one task made
|
|
35
|
+
// core refuse the whole run (2026-09-28). The façade does not export the
|
|
36
|
+
// check; tests/own-file-outputs.test.ts holds the config half of this copy
|
|
37
|
+
// to the loader in both directions.
|
|
36
38
|
const OWN_FILES = ['package.json', 'vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.config.mjs']
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -102,7 +104,7 @@ export function ownFileTodo(own: { glob: string; file: string }): string {
|
|
|
102
104
|
export function wildcardOutput(
|
|
103
105
|
files: readonly string[],
|
|
104
106
|
tracked?: TrackedKinds,
|
|
105
|
-
/** Let a literal-tail glob through
|
|
107
|
+
/** Let a literal-tail glob through: it reaches only the committed files it names, which core keeps (`literalTailMatches`). */
|
|
106
108
|
literalTail = false,
|
|
107
109
|
): string | undefined {
|
|
108
110
|
return files.find(
|
|
@@ -110,20 +112,16 @@ export function wildcardOutput(
|
|
|
110
112
|
!o.startsWith('!') &&
|
|
111
113
|
!isLiteralPattern(o.split('/')[0] ?? '') &&
|
|
112
114
|
!untrackedKind(o, tracked) &&
|
|
113
|
-
!(
|
|
114
|
-
literalTail &&
|
|
115
|
-
(literalTailMatches(o, tracked)?.every((m) => files.includes(`!${m}`)) ?? false)
|
|
116
|
-
),
|
|
115
|
+
!(literalTail && literalTailMatches(o, tracked) !== null),
|
|
117
116
|
)
|
|
118
117
|
}
|
|
119
118
|
|
|
120
119
|
/**
|
|
121
120
|
* The tracked files a wildcard-first glob with a literal rest can reach
|
|
122
121
|
* (clerk's builds output `*\/package.json`, the subpath stubs it commits):
|
|
123
|
-
* it reaches no other file,
|
|
124
|
-
* reaches no source. Null for any other shape, or with `tracked` unknown.
|
|
122
|
+
* it reaches no other file, and core never cleans a committed one. Null for any other shape, or with `tracked` unknown.
|
|
125
123
|
*/
|
|
126
|
-
|
|
124
|
+
function literalTailMatches(glob: string, tracked?: TrackedKinds): string[] | null {
|
|
127
125
|
if (tracked === undefined || glob.startsWith('!')) return null
|
|
128
126
|
const segs = glob.replace(/^(\.\/)+/, '').split('/')
|
|
129
127
|
const [first, ...rest] = segs
|
|
@@ -159,6 +157,15 @@ export function wildcardTodo(glob: string): string {
|
|
|
159
157
|
)
|
|
160
158
|
}
|
|
161
159
|
|
|
160
|
+
export function wildcardCleanTodo(glob: string): string {
|
|
161
|
+
return (
|
|
162
|
+
`output ${JSON.stringify(glob)}: vx cleans outputs before a run and a restore, so every ` +
|
|
163
|
+
'untracked file this glob reaches is deleted; committed files are kept'
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const TSBUILDINFO = /(?:^|\/)[^/]*tsbuildinfo[^/]*$/i
|
|
168
|
+
|
|
162
169
|
interface Cached {
|
|
163
170
|
index: number
|
|
164
171
|
name: string
|
|
@@ -204,7 +211,12 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
|
|
|
204
211
|
)
|
|
205
212
|
if (group.length === 0) continue
|
|
206
213
|
const all = [a, ...group]
|
|
207
|
-
|
|
214
|
+
// `build` is the product Turbo and Nx repos run: typescript-eslint's
|
|
215
|
+
// typecheck (with `^typecheck`) writes declarations into build's dist,
|
|
216
|
+
// and keeping typecheck left every build uncached (Growth parity,
|
|
217
|
+
// 2026-10-14).
|
|
218
|
+
const keeper =
|
|
219
|
+
all.find((c) => c.name === 'build') ?? all.find((c) => c.hasUpstreamEdge) ?? all[0]!
|
|
208
220
|
const kept: Cached[] = [keeper]
|
|
209
221
|
for (const c of all) {
|
|
210
222
|
if (c === keeper) continue
|
|
@@ -215,8 +227,28 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
|
|
|
215
227
|
kept.push(c)
|
|
216
228
|
continue
|
|
217
229
|
}
|
|
218
|
-
dropped.add(c.index)
|
|
219
230
|
const t = tasks[c.index]!
|
|
231
|
+
// tsc's incremental state is no product: create-t3-turbo's build and
|
|
232
|
+
// typecheck both output `.cache/tsbuildinfo.json`, and typecheck ran
|
|
233
|
+
// uncached on every first run (Growth funnel F8). Left to the keeper,
|
|
234
|
+
// it costs a task that emits nothing else nothing to cache. A task
|
|
235
|
+
// that emits more stays uncached: `tsc --incremental` skips a file
|
|
236
|
+
// the keeper's tsbuildinfo calls built, missing or not, so its save
|
|
237
|
+
// could be a partial dist.
|
|
238
|
+
const sharedFiles = c.files.filter((gb) =>
|
|
239
|
+
kept.some((k) => k.files.some((ga) => outputsOverlap(ga, gb))),
|
|
240
|
+
)
|
|
241
|
+
if (
|
|
242
|
+
sharedFiles.length === c.files.length &&
|
|
243
|
+
sharedFiles.every((g) => isLiteralPattern(g) && TSBUILDINFO.test(g))
|
|
244
|
+
) {
|
|
245
|
+
c.files = c.files.filter((g) => !sharedFiles.includes(g))
|
|
246
|
+
const outputs = (t.task!['cache'] as { outputs: { files: string[] } }).outputs
|
|
247
|
+
outputs.files = outputs.files.filter((g) => !sharedFiles.includes(g))
|
|
248
|
+
kept.push(c)
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
dropped.add(c.index)
|
|
220
252
|
const shared = c.files.find((gb) => clash.files.some((ga) => outputsOverlap(ga, gb)))!
|
|
221
253
|
delete t.task!['cache']
|
|
222
254
|
t.todos.push(
|
|
@@ -276,6 +308,15 @@ function prefixIndex(): {
|
|
|
276
308
|
}
|
|
277
309
|
}
|
|
278
310
|
|
|
311
|
+
/**
|
|
312
|
+
* A keyed group (`cache`, no `exec`: the `nx-input:<name>` twins, Turbo's
|
|
313
|
+
* transit nodes) runs nothing, so core lets its key read what a task
|
|
314
|
+
* writes (`detectInputOverlaps` exempts it). Uncaching one left `{}`, a
|
|
315
|
+
* task core refuses, and every verb failed.
|
|
316
|
+
*/
|
|
317
|
+
export const isKeyedGroup = (t: { readonly task: Record<string, unknown> | null }): boolean =>
|
|
318
|
+
t.task !== null && t.task['exec'] === undefined && t.task['cache'] !== undefined
|
|
319
|
+
|
|
279
320
|
/** A task's cached `inputs` block, when it has one with a `files` list. */
|
|
280
321
|
function inputsOf(t: GeneratedTask): { files: string[]; workspaceFiles?: unknown } | undefined {
|
|
281
322
|
const inputs = (t.task?.['cache'] as { inputs?: { files?: unknown } } | undefined)?.inputs
|
|
@@ -294,8 +335,8 @@ function inputsOf(t: GeneratedTask): { files: string[]; workspaceFiles?: unknown
|
|
|
294
335
|
* files git tracks, and a build's outputs are not, so the adopted tool
|
|
295
336
|
* never keyed them either. A literal input another task writes cannot be
|
|
296
337
|
* taken back (core refuses a `!` over a literal input), so that task runs
|
|
297
|
-
* uncached with a todo.
|
|
298
|
-
* pass's (`spareTrackedOutputs`).
|
|
338
|
+
* uncached with a todo. A reader whose `!` hides a committed file is the
|
|
339
|
+
* tracked pass's (`spareTrackedOutputs`).
|
|
299
340
|
*/
|
|
300
341
|
export function excludeSiblingOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
|
|
301
342
|
const writers = tasks.flatMap((t, index) => {
|
|
@@ -314,6 +355,7 @@ export function excludeSiblingOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
|
|
|
314
355
|
const read = positive.filter((g) => outputsOverlap(g, out))
|
|
315
356
|
if (read.length === 0) continue
|
|
316
357
|
const literal = read.find((g) => isLiteralPattern(g))
|
|
358
|
+
if (literal !== undefined && isKeyedGroup(t)) continue
|
|
317
359
|
if (literal !== undefined) {
|
|
318
360
|
delete t.task!['cache']
|
|
319
361
|
t.todos.push(
|
|
@@ -463,6 +505,7 @@ export function excludeWorkspaceOutputs(
|
|
|
463
505
|
const read = reads.filter((g) => outputsOverlap(g, out))
|
|
464
506
|
if (read.length === 0) continue
|
|
465
507
|
const literal = read.find((g) => isLiteralPattern(g))
|
|
508
|
+
if (literal !== undefined && isKeyedGroup(t)) continue
|
|
466
509
|
if (literal !== undefined) {
|
|
467
510
|
delete t.task!['cache']
|
|
468
511
|
t.todos.push(
|
|
@@ -497,6 +540,7 @@ export function excludeWorkspaceOutputs(
|
|
|
497
540
|
// An output that reaches in from above (`**/gen/**`) has no
|
|
498
541
|
// project-relative `!` that takes it back.
|
|
499
542
|
const literal = out.startsWith(at) ? read.find((g) => isLiteralPattern(g)) : read[0]
|
|
543
|
+
if (literal !== undefined && isKeyedGroup(t)) continue
|
|
500
544
|
if (literal !== undefined) {
|
|
501
545
|
delete t.task!['cache']
|
|
502
546
|
t.todos.push(
|
package/src/tracked-outputs.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { lstat, readFile } from 'node:fs/promises'
|
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import { isLiteralPattern, outputsOverlap } from '@vzn/vx'
|
|
13
13
|
import { relPosix } from './paths.js'
|
|
14
|
+
import { isKeyedGroup } from './shared-outputs.js'
|
|
14
15
|
|
|
15
16
|
/** The files git tracks under `root`, root-relative; null outside a repo or without git. */
|
|
16
17
|
export async function trackedFiles(root: string): Promise<string[] | null> {
|
|
@@ -118,8 +119,8 @@ export function trackedKinds(tracked: readonly string[]): (rel: string) => Track
|
|
|
118
119
|
* checkout, a pull appends to it), HEAD itself, and the index's size and
|
|
119
120
|
* mtime (`git add`, `git rm`). Stats and a small read, so a kept mapping
|
|
120
121
|
* stays a hit between them; a run does not write the index. Without the
|
|
121
|
-
* index, a file `git add`ed under an output and not yet committed
|
|
122
|
-
*
|
|
122
|
+
* index, a file `git add`ed under an output and not yet committed left a
|
|
123
|
+
* reader that its `!` hides cached by the kept mapping. Under
|
|
123
124
|
* reftable storage HEAD reads `ref: refs/heads/.invalid` and no reflog file
|
|
124
125
|
* exists, so the ref stack's table list stands for both: every ref update
|
|
125
126
|
* names a new table in it.
|
|
@@ -191,8 +192,25 @@ function hidden(globs: unknown, files: readonly string[]): string | undefined {
|
|
|
191
192
|
)
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
195
|
+
/** `globs` without the `!` entries in `backs` that take back one of `files`. */
|
|
196
|
+
function unhidden(
|
|
197
|
+
globs: readonly unknown[],
|
|
198
|
+
backs: ReadonlySet<string>,
|
|
199
|
+
files: readonly string[],
|
|
200
|
+
): unknown[] {
|
|
201
|
+
return globs.filter(
|
|
202
|
+
(g) =>
|
|
203
|
+
!(
|
|
204
|
+
typeof g === 'string' &&
|
|
205
|
+
g.startsWith('!') &&
|
|
206
|
+
backs.has(g.slice(1)) &&
|
|
207
|
+
files.some((f) => outputsOverlap(g.slice(1), f))
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const positives = (v: unknown): string[] =>
|
|
213
|
+
Array.isArray(v) ? v.filter((g): g is string => typeof g === 'string' && !g.startsWith('!')) : []
|
|
196
214
|
|
|
197
215
|
interface Outputs {
|
|
198
216
|
files?: unknown
|
|
@@ -200,9 +218,11 @@ interface Outputs {
|
|
|
200
218
|
}
|
|
201
219
|
|
|
202
220
|
/**
|
|
203
|
-
*
|
|
204
|
-
* and return one `[pkg#task, todo]` per task
|
|
205
|
-
*
|
|
221
|
+
* Fit a mapped task's outputs to the committed files they cover, in place,
|
|
222
|
+
* and return one `[pkg#task, todo]` per task this leaves uncached. Core
|
|
223
|
+
* never cleans a committed file (owner, 2026-10-14), so
|
|
224
|
+
* the outputs keep them; what is left is the readers' `!` that hides one.
|
|
225
|
+
* `tracked` is root-relative.
|
|
206
226
|
*/
|
|
207
227
|
export function spareTrackedOutputs(
|
|
208
228
|
root: string,
|
|
@@ -244,7 +264,7 @@ export function spareTrackedOutputs(
|
|
|
244
264
|
for (const t of p.tasks) {
|
|
245
265
|
const outputs = (t.task?.['cache'] as { outputs?: Outputs } | undefined)?.outputs
|
|
246
266
|
if (outputs === undefined) continue
|
|
247
|
-
const hits: [key: 'files' | 'workspaceFiles',
|
|
267
|
+
const hits: [key: 'files' | 'workspaceFiles', hit: string[]][] = []
|
|
248
268
|
for (const [key, base] of [
|
|
249
269
|
['files', own],
|
|
250
270
|
['workspaceFiles', tracked],
|
|
@@ -253,9 +273,9 @@ export function spareTrackedOutputs(
|
|
|
253
273
|
if (!Array.isArray(globs) || globs.length === 0) continue
|
|
254
274
|
let strings = globs.filter((g): g is string => typeof g === 'string')
|
|
255
275
|
// An output naming one committed file (eslint's
|
|
256
|
-
// `eslint-suppressions.json`) is a source:
|
|
257
|
-
//
|
|
258
|
-
//
|
|
276
|
+
// `eslint-suppressions.json`) is a source: the entry goes, and so
|
|
277
|
+
// does the `!` a reader's inputs got for it, or every reader ran
|
|
278
|
+
// uncached (hono).
|
|
259
279
|
const rootedOf = (g: string) => (key === 'files' && rel !== '' ? `${rel}/${g}` : g)
|
|
260
280
|
const sources = new Set(
|
|
261
281
|
strings.filter((g) => isLiteralPattern(g) && trackedSet.has(rootedOf(g))),
|
|
@@ -266,17 +286,26 @@ export function spareTrackedOutputs(
|
|
|
266
286
|
for (const g of sources) unhide(key === 'files' ? p : null, rootedOf(g))
|
|
267
287
|
}
|
|
268
288
|
const hit = coveredTracked(strings, base)
|
|
269
|
-
if (hit.length > 0) hits.push([key,
|
|
289
|
+
if (hit.length > 0) hits.push([key, hit])
|
|
270
290
|
}
|
|
271
|
-
|
|
272
|
-
if (spared.length === 0) continue
|
|
291
|
+
if (hits.length === 0) continue
|
|
273
292
|
// A committed file under these outputs is still a source, and the `!`
|
|
274
293
|
// entry `excludeSiblingOutputs` gave a reader over the whole output
|
|
275
294
|
// hides it from that reader's key: such a reader runs uncached (X-54).
|
|
276
|
-
const local = hits.flatMap(([key,
|
|
277
|
-
const rooted = hits.flatMap(([key,
|
|
295
|
+
const local = hits.flatMap(([key, hit]) => (key === 'files' ? hit : []))
|
|
296
|
+
const rooted = hits.flatMap(([key, hit]) =>
|
|
278
297
|
key === 'files' ? hit.map((f) => (rel === '' ? f : `${rel}/${f}`)) : hit,
|
|
279
298
|
)
|
|
299
|
+
// The `!` entries the sibling passes gave readers for these outputs.
|
|
300
|
+
const ownOut = positives(outputs.files)
|
|
301
|
+
const fileBacks = new Set(ownOut)
|
|
302
|
+
const wsBacks = new Set([
|
|
303
|
+
...positives(outputs.workspaceFiles),
|
|
304
|
+
...ownOut.map((g) => {
|
|
305
|
+
const bare = g.replace(/^(\.\/)+/, '')
|
|
306
|
+
return rel === '' ? bare : `${rel}/${bare}`
|
|
307
|
+
}),
|
|
308
|
+
])
|
|
280
309
|
for (const q of projects) {
|
|
281
310
|
for (const r of q.tasks) {
|
|
282
311
|
if (r === t) continue
|
|
@@ -286,6 +315,15 @@ export function spareTrackedOutputs(
|
|
|
286
315
|
(q === p ? hidden(inputs.files, local) : undefined) ??
|
|
287
316
|
hidden(inputs.workspaceFiles, rooted)
|
|
288
317
|
if (f === undefined) continue
|
|
318
|
+
if (isKeyedGroup(r)) {
|
|
319
|
+
// Its key reads the committed files, as Nx hashes them. Only the
|
|
320
|
+
// take-backs of this output go, never a `!` the input declares.
|
|
321
|
+
if (q === p && Array.isArray(inputs.files))
|
|
322
|
+
inputs.files = unhidden(inputs.files, fileBacks, local)
|
|
323
|
+
if (Array.isArray(inputs.workspaceFiles))
|
|
324
|
+
inputs.workspaceFiles = unhidden(inputs.workspaceFiles, wsBacks, rooted)
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
289
327
|
delete r.task!['cache']
|
|
290
328
|
todos.push([
|
|
291
329
|
`${q.name}#${r.name}`,
|
|
@@ -294,25 +332,6 @@ export function spareTrackedOutputs(
|
|
|
294
332
|
])
|
|
295
333
|
}
|
|
296
334
|
}
|
|
297
|
-
const shown = spared.slice(0, 3).join(', ') + (spared.length > 3 ? ', …' : '')
|
|
298
|
-
// Each `!` is matched against every output path on each save, clean
|
|
299
|
-
// and restore: 4,200 of them cost typescript-eslint's warm run 1.2 s.
|
|
300
|
-
if (spared.length > MAX_SPARED) {
|
|
301
|
-
delete t.task!['cache']
|
|
302
|
-
todos.push([
|
|
303
|
-
`${p.name}#${t.name}`,
|
|
304
|
-
`outputs cover ${spared.length} committed files (${shown}), which vx cleans before ` +
|
|
305
|
-
'every run — task runs uncached; declare the exact outputs in a vx.config to cache it',
|
|
306
|
-
])
|
|
307
|
-
continue
|
|
308
|
-
}
|
|
309
|
-
for (const [key, strings, hit] of hits)
|
|
310
|
-
outputs[key] = [...strings, ...hit.map((f) => `!${f}`)]
|
|
311
|
-
todos.push([
|
|
312
|
-
`${p.name}#${t.name}`,
|
|
313
|
-
`outputs cover ${spared.length} committed file(s) (${shown}) — vx cleans outputs ` +
|
|
314
|
-
'before a run, so they are taken back with `!` and kept',
|
|
315
|
-
])
|
|
316
335
|
}
|
|
317
336
|
}
|
|
318
337
|
return todos
|
package/src/turbo/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Fills the `project` stage: every package the workspace discovers is given
|
|
4
4
|
// the tasks turbo.json + its package.json scripts define, mapped by the same
|
|
5
|
-
// mapper `vx
|
|
5
|
+
// mapper `bunx @vzn/vx-migrate` renders files from — so what runs here is
|
|
6
6
|
// what a migration would have written, minus the file. Turbo's global fields
|
|
7
7
|
// are inlined into each task instead of imported from a generated preset.
|
|
8
8
|
// A task the package's own vx.config already declares wins; the plugin never
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
import { readFileSync } from 'node:fs'
|
|
12
12
|
import { availableParallelism } from 'node:os'
|
|
13
13
|
import path from 'node:path'
|
|
14
|
-
import type { ProjectMeta, VxPlugin } from '@vzn/vx'
|
|
14
|
+
import type { ProjectMeta, VxPlugin, WorkspaceConfig } from '@vzn/vx'
|
|
15
|
+
import type { PackageMeta } from '../packages.js'
|
|
16
|
+
import { memberProjects, withManifests } from '../manifests.js'
|
|
15
17
|
import { refuseUnknownOptions, type PluginOptionKinds } from '@vzn/vx'
|
|
16
18
|
import { type AdoptionRun, adoptionPlugin } from '../adoption-plugin.js'
|
|
17
19
|
import type { AdoptionMapping } from '../mapping-cache.js'
|
|
@@ -60,29 +62,38 @@ export function turbo(options: TurboPluginOptions = {}): VxPlugin {
|
|
|
60
62
|
{
|
|
61
63
|
async config(workspace, ctx) {
|
|
62
64
|
const keys = await workspaceKeys(options.root ?? ctx.workspaceRoot)
|
|
63
|
-
if (workspace.concurrency === undefined && keys.concurrency !== undefined)
|
|
64
|
-
workspace.
|
|
65
|
-
if (workspace.
|
|
66
|
-
workspace.
|
|
65
|
+
if (workspace.run?.concurrency === undefined && keys.concurrency !== undefined)
|
|
66
|
+
workspace.run = { ...workspace.run, concurrency: keys.concurrency }
|
|
67
|
+
if (workspace.cache?.retention === undefined && keys.cacheRetention !== undefined)
|
|
68
|
+
workspace.cache = {
|
|
69
|
+
...workspace.cache,
|
|
70
|
+
// `olderThan` is built with its unit below.
|
|
71
|
+
retention: keys.cacheRetention as NonNullable<
|
|
72
|
+
NonNullable<WorkspaceConfig['cache']>['retention']
|
|
73
|
+
>,
|
|
74
|
+
}
|
|
67
75
|
// `turbo run --affected` compares with TURBO_SCM_BASE when set, and
|
|
68
76
|
// on GitHub Actions with what the event names.
|
|
69
77
|
const base =
|
|
70
78
|
Bun.env['TURBO_SCM_BASE']?.trim() ||
|
|
71
79
|
githubActionsBase(options.root ?? ctx.workspaceRoot, keys.remoteBaseFallback === true)
|
|
72
|
-
if (workspace.affectedBase === undefined && base)
|
|
80
|
+
if (workspace.run?.affectedBase === undefined && base)
|
|
81
|
+
workspace.run = { ...workspace.run, affectedBase: base }
|
|
73
82
|
},
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// the
|
|
83
|
+
// Turbo's packages are the package manager's members, each a project
|
|
84
|
+
// with no vx.config written. `//#task` keys run on the root, as Turbo
|
|
85
|
+
// runs them: named here, the root is a project too (a create-turbo
|
|
86
|
+
// repo's `//#format`). A package that holds the root's name keeps it,
|
|
87
|
+
// and the mapping's note says so.
|
|
78
88
|
async discover(ctx) {
|
|
79
|
-
|
|
89
|
+
const members = await memberProjects(ctx.workspaceRoot)
|
|
90
|
+
if (options.root !== undefined) return members
|
|
80
91
|
const named = await rootTaskProject(ctx.workspaceRoot)
|
|
81
|
-
if (named === null) return
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
)
|
|
85
|
-
return taken ?
|
|
92
|
+
if (named === null) return members
|
|
93
|
+
const rootDir = path.resolve(named.dir)
|
|
94
|
+
const known = [...ctx.projects, ...members.map((m) => ({ name: m.name, dir: m.dir }))]
|
|
95
|
+
const taken = known.some((m) => m.name === named.name && path.resolve(m.dir) !== rootDir)
|
|
96
|
+
return taken ? members : [...members.filter((m) => path.resolve(m.dir) !== rootDir), named]
|
|
86
97
|
},
|
|
87
98
|
},
|
|
88
99
|
)
|
|
@@ -229,7 +240,8 @@ const textOf = (file: string): Promise<string> =>
|
|
|
229
240
|
* `turbo.json` / `turbo.jsonc`, every package manifest, the root's,
|
|
230
241
|
* `.yarnrc.yml` and the microfrontends configs.
|
|
231
242
|
*/
|
|
232
|
-
async function run(root: string,
|
|
243
|
+
async function run(root: string, projects: readonly ProjectMeta[]): Promise<AdoptionRun> {
|
|
244
|
+
const metas = await withManifests(projects)
|
|
233
245
|
const dirs = [root, ...metas.map((m) => m.dir)]
|
|
234
246
|
const configs = await Promise.all(
|
|
235
247
|
dirs.flatMap((d) => ['turbo.json', 'turbo.jsonc'].map((f) => textOf(path.join(d, f)))),
|
|
@@ -264,7 +276,7 @@ async function run(root: string, metas: readonly ProjectMeta[]): Promise<Adoptio
|
|
|
264
276
|
*/
|
|
265
277
|
async function mapAll(
|
|
266
278
|
root: string,
|
|
267
|
-
metas: readonly
|
|
279
|
+
metas: readonly PackageMeta[],
|
|
268
280
|
envNames: readonly string[],
|
|
269
281
|
trackedFiles: () => Promise<readonly string[] | null>,
|
|
270
282
|
): Promise<AdoptionMapping> {
|
|
@@ -275,7 +287,7 @@ async function mapAll(
|
|
|
275
287
|
),
|
|
276
288
|
)
|
|
277
289
|
const mapped = await mapTurboWorkspace(root, metas, {
|
|
278
|
-
// Inline: the values themselves, where `vx
|
|
290
|
+
// Inline: the values themselves, where `bunx @vzn/vx-migrate` splices a preset import.
|
|
279
291
|
splice: (_kind, values) => values,
|
|
280
292
|
persistentTodo: PERSISTENT_NOTE,
|
|
281
293
|
envNames,
|