@vzn/vx-migrate 0.0.0 → 0.0.484
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/LICENSE +21 -0
- package/README.md +306 -0
- package/index.ts +4 -0
- package/package.json +44 -6
- package/src/adoption-plugin.ts +125 -0
- package/src/bin.ts +18 -0
- package/src/dotenv-probe.ts +86 -0
- package/src/glob-grammar.ts +63 -0
- package/src/index.ts +15 -0
- package/src/mapping-cache.ts +101 -0
- package/src/migrate-nx.ts +154 -0
- package/src/migrate-turbo.ts +400 -0
- package/src/migrate.ts +165 -0
- package/src/nx/export-graph.ts +29 -0
- package/src/nx/index.ts +638 -0
- package/src/nx/nx-deps.ts +272 -0
- package/src/nx/nx-dotenv.ts +106 -0
- package/src/nx/nx-inputs.ts +210 -0
- package/src/nx/nx-map.ts +1430 -0
- package/src/nx/nx-outputs.ts +231 -0
- package/src/nx/nx-upstream.ts +356 -0
- package/src/nx-cache/index.ts +236 -0
- package/src/nx-command.ts +458 -0
- package/src/nx-dotenv.cjs +41 -0
- package/src/nx-env.cjs +152 -0
- package/src/nx-exec.cjs +294 -0
- package/src/paths.ts +36 -0
- package/src/plugin-gaps.ts +48 -0
- package/src/remote-deadline.ts +11 -0
- package/src/remote-retry.ts +52 -0
- package/src/remote-token.ts +18 -0
- package/src/script-command.ts +69 -0
- package/src/shared-outputs.ts +373 -0
- package/src/tracked-outputs.ts +248 -0
- package/src/turbo/index.ts +291 -0
- package/src/turbo/turbo-map.ts +1976 -0
- package/src/turbo-cache/index.ts +661 -0
- package/src/workspace-notes.ts +54 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// An Nx target's `dependsOn` as vx edges. Nx separates a specific
|
|
2
|
+
// project's target with a COLON (`ui:build`); vx's separator is `#`.
|
|
3
|
+
// Passed through, the string form read as a task named `ui:build` in the
|
|
4
|
+
// DEPENDENT's own project and the migrated workspace refused to run —
|
|
5
|
+
// "depends on web#ui:build but no such task is declared", from a config
|
|
6
|
+
// vx-migrate itself wrote (walked the Nx path, 2026-09-20). Extracted from
|
|
7
|
+
// `buildTask` in item 606; the rules are unchanged.
|
|
8
|
+
|
|
9
|
+
import type { ProjectMeta } from '@vzn/vx'
|
|
10
|
+
|
|
11
|
+
/** The vx task an Nx `project:target:configuration` reaches, or null when the target lacks it. */
|
|
12
|
+
export type TaskNameFor = (project: string, target: string, configuration: string) => string | null
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Nx adds an edge to a project's target only when the project HAS that
|
|
16
|
+
* target (`processTasksForSingleProject`), and says nothing otherwise:
|
|
17
|
+
* nx-examples' `targetDefaults` give every `typecheck` a `codegen` that
|
|
18
|
+
* one project declares. Passed through, core refused the whole run —
|
|
19
|
+
* "depends on …#codegen but no such task is declared". Dropped here as
|
|
20
|
+
* Nx drops it, for this project and a named one alike.
|
|
21
|
+
*/
|
|
22
|
+
export type HasTarget = (project: string, target: string) => boolean
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The graph nodes a `projects` list names, ported from Nx's
|
|
26
|
+
* `findMatchingProjects` (nx 23.2): a `name:`, `tag:` or `directory:`
|
|
27
|
+
* label, or none, which tries the name and then the project's root
|
|
28
|
+
* directory; minimatch globs (`dot`); a bare word that names no project
|
|
29
|
+
* matches names it stands in as a word, case-insensitive (`foo` is
|
|
30
|
+
* `foo_bar`, not `foo-e2e`); `!` excludes, and a list that opens with an
|
|
31
|
+
* exclusion starts from every node. vx read names and `*` alone, so
|
|
32
|
+
* `libs/shared/*` (a directory) matched nothing and its edge or input
|
|
33
|
+
* was dropped.
|
|
34
|
+
*/
|
|
35
|
+
export function matchNxProjects(
|
|
36
|
+
patterns: readonly string[],
|
|
37
|
+
nodes: ReadonlyArray<{
|
|
38
|
+
readonly name: string
|
|
39
|
+
readonly tags: readonly string[]
|
|
40
|
+
readonly root?: string
|
|
41
|
+
}>,
|
|
42
|
+
): string[] {
|
|
43
|
+
if (patterns.every((p) => p.length === 0)) return []
|
|
44
|
+
const byName = new Map(nodes.map((n) => [n.name, n]))
|
|
45
|
+
const glob = (p: string, s: string): boolean => s === p || new Bun.Glob(p).match(s)
|
|
46
|
+
const out = new Set<string>()
|
|
47
|
+
const list = patterns[0]!.startsWith('!') ? ['*', ...patterns] : patterns
|
|
48
|
+
for (const raw of list) {
|
|
49
|
+
if (raw.length === 0 || raw.startsWith('nx-cloud:')) continue
|
|
50
|
+
const exclude = raw.startsWith('!')
|
|
51
|
+
let value = exclude ? raw.slice(1) : raw
|
|
52
|
+
let type = 'unlabeled'
|
|
53
|
+
if (byName.has(value)) type = 'name'
|
|
54
|
+
else if (value.includes(':')) {
|
|
55
|
+
// Nx drops the head whether or not it is a label it knows.
|
|
56
|
+
const head = value.slice(0, value.indexOf(':'))
|
|
57
|
+
if (['name', 'tag', 'directory'].includes(head)) type = head
|
|
58
|
+
value = value.slice(value.indexOf(':') + 1)
|
|
59
|
+
}
|
|
60
|
+
const put = (name: string): void => {
|
|
61
|
+
if (exclude) out.delete(name)
|
|
62
|
+
else out.add(name)
|
|
63
|
+
}
|
|
64
|
+
if (value === '*') {
|
|
65
|
+
for (const n of nodes) put(n.name)
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
const pattern = TARGET_GLOB.test(value)
|
|
69
|
+
const byNames = (): void => {
|
|
70
|
+
if (byName.has(value)) return put(value)
|
|
71
|
+
if (!pattern) {
|
|
72
|
+
const word = new RegExp(`(?<![@a-zA-Z0-9-])${value}(?![@a-zA-Z0-9-])`, 'i')
|
|
73
|
+
for (const n of nodes) if (word.test(n.name)) put(n.name)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
for (const n of nodes) if (glob(value, n.name)) put(n.name)
|
|
77
|
+
}
|
|
78
|
+
const byDirectory = (): void => {
|
|
79
|
+
for (const n of nodes) if (n.root !== undefined && glob(value, n.root)) put(n.name)
|
|
80
|
+
}
|
|
81
|
+
if (type === 'tag') {
|
|
82
|
+
for (const n of nodes)
|
|
83
|
+
if (n.tags.includes(value) || (pattern && n.tags.some((t) => glob(value, t)))) put(n.name)
|
|
84
|
+
} else if (type === 'name') byNames()
|
|
85
|
+
else if (type === 'directory') byDirectory()
|
|
86
|
+
else {
|
|
87
|
+
const size = out.size
|
|
88
|
+
byNames()
|
|
89
|
+
if (out.size === size) byDirectory()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [...out]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Nx's `isGlobPattern`: a target holding one of these is a pattern. */
|
|
96
|
+
const TARGET_GLOB = /[*|{}()[]/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Nx (19.5+) expands a target glob in `dependsOn` over every target name
|
|
100
|
+
* in the workspace (`expandWildcardTargetConfiguration`), after reading a
|
|
101
|
+
* `project:` head, and keeps the edge where the project has the target.
|
|
102
|
+
* Read as `project:target`, TanStack/router's `test:e2e--*` named project
|
|
103
|
+
* `test` and each of 140 aggregators lost the modes it fans out to
|
|
104
|
+
* (2026-09-28).
|
|
105
|
+
*/
|
|
106
|
+
function expandTargetGlobs(
|
|
107
|
+
entries: readonly unknown[],
|
|
108
|
+
targetNames: readonly string[],
|
|
109
|
+
isProject: (name: string) => boolean,
|
|
110
|
+
ownTarget: (name: string) => boolean,
|
|
111
|
+
): unknown[] {
|
|
112
|
+
const expand = (pattern: string): string[] => {
|
|
113
|
+
const glob = new Bun.Glob(pattern)
|
|
114
|
+
return targetNames.filter((t) => glob.match(t))
|
|
115
|
+
}
|
|
116
|
+
// A glob's match another entry names already is that entry's edge (`^bui*` beside `^build`).
|
|
117
|
+
const literal = new Set(entries.filter((d) => typeof d === 'string'))
|
|
118
|
+
const fresh = (ds: string[]): string[] => ds.filter((d) => !literal.has(d))
|
|
119
|
+
return entries.flatMap((d): unknown[] => {
|
|
120
|
+
if (typeof d === 'string') {
|
|
121
|
+
if (!TARGET_GLOB.test(d)) return [d]
|
|
122
|
+
if (d.startsWith('^')) return fresh(expand(d.slice(1)).map((t) => `^${t}`))
|
|
123
|
+
const colon = d.indexOf(':')
|
|
124
|
+
const head = colon > 0 ? d.slice(0, colon) : ''
|
|
125
|
+
if (head !== '' && isProject(head)) {
|
|
126
|
+
return fresh(expand(d.slice(colon + 1)).map((t) => `${head}:${t}`))
|
|
127
|
+
}
|
|
128
|
+
// A same-project glob keeps only this project's targets: a match
|
|
129
|
+
// another project declares (`test:e2e--webkit`) read as project
|
|
130
|
+
// `test` and drew a dropped-edge todo per aggregator (TanStack/router).
|
|
131
|
+
return fresh(expand(d).filter(ownTarget))
|
|
132
|
+
}
|
|
133
|
+
if (d && typeof d === 'object') {
|
|
134
|
+
const t = (d as Record<string, unknown>).target
|
|
135
|
+
if (typeof t === 'string' && TARGET_GLOB.test(t)) {
|
|
136
|
+
return expand(t).map((target) => ({ ...(d as Record<string, unknown>), target }))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return [d]
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function mapNxDeps(
|
|
144
|
+
raw: readonly unknown[],
|
|
145
|
+
metaByNode: ReadonlyMap<string, ProjectMeta>,
|
|
146
|
+
ownTarget: (name: string) => boolean,
|
|
147
|
+
taskNameFor: TaskNameFor,
|
|
148
|
+
hasTarget: HasTarget,
|
|
149
|
+
todos: string[],
|
|
150
|
+
matchProjects: (patterns: readonly string[]) => string[] = (ps) => [...ps],
|
|
151
|
+
targetNames: readonly string[] = [],
|
|
152
|
+
asked?: { readonly node: string; readonly configuration: string },
|
|
153
|
+
/** This target's options, its configuration's merged in: what `options: "forward"` hands on. */
|
|
154
|
+
forwarded?: Readonly<Record<string, unknown>>,
|
|
155
|
+
): string[] {
|
|
156
|
+
const entries = expandTargetGlobs(raw, targetNames, (p) => metaByNode.has(p), ownTarget)
|
|
157
|
+
const deps: string[] = []
|
|
158
|
+
// Said once per task and without the dependency's name: cypress's
|
|
159
|
+
// atomized `e2e-ci` forwards params to each spec's task, and a line
|
|
160
|
+
// per spec name was a warning line per spec on every run.
|
|
161
|
+
const forwards = { options: false, params: false }
|
|
162
|
+
// Nx hands every edge the configuration the run asked for, and each
|
|
163
|
+
// target runs it where it declares it, else its default
|
|
164
|
+
// (`resolveConfiguration`). `build:ci`'s `lint` ran lint's default.
|
|
165
|
+
const named = (project: string, t: string): string =>
|
|
166
|
+
(asked && taskNameFor(project, t, asked.configuration)) ?? t
|
|
167
|
+
const self = asked?.node ?? ''
|
|
168
|
+
for (const d of entries) {
|
|
169
|
+
if (typeof d === 'string') {
|
|
170
|
+
// `^name` is every dependency's `name`, whatever the name holds: the
|
|
171
|
+
// colon in `^rsbuild:typecheck` (an inferred target) is the target's
|
|
172
|
+
// own, and splitting there read project `^rsbuild`, which no graph
|
|
173
|
+
// has, and dropped the edge.
|
|
174
|
+
if (d.startsWith('^')) {
|
|
175
|
+
deps.push(d)
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
const colon = d.indexOf(':')
|
|
179
|
+
if (colon <= 0) {
|
|
180
|
+
if (ownTarget(d)) deps.push(named(self, d))
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
const project = d.slice(0, colon)
|
|
184
|
+
const rest = d.slice(colon + 1)
|
|
185
|
+
const m = metaByNode.get(project)
|
|
186
|
+
// Nx splits at the colon only when the head names a project; else
|
|
187
|
+
// the whole string is a target of this project (`test:unit`, as
|
|
188
|
+
// script-inferred targets are named). It was read as project `test`
|
|
189
|
+
// and the edge dropped (item 915). This project's own target ranks
|
|
190
|
+
// first (`splitTargetFromNodes`).
|
|
191
|
+
if (ownTarget(d)) {
|
|
192
|
+
deps.push(named(self, d))
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
if (m === undefined || rest === '') {
|
|
196
|
+
todos.push(
|
|
197
|
+
`dependsOn ${JSON.stringify(d)} names ${JSON.stringify(project)}, which is not a ` +
|
|
198
|
+
'workspace package in this graph — edge dropped',
|
|
199
|
+
)
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
// The rest is ONE target name, colons and all, as Nx's
|
|
203
|
+
// `readProjectAndTargetFromTargetString` joins it: `ui:build:esm` is
|
|
204
|
+
// ui's `build:esm`, and `ui:build:ci` names target `build:ci`, never
|
|
205
|
+
// build's `ci` configuration (the run's configuration is what an edge
|
|
206
|
+
// passes on). Nx draws no edge where ui has no such target; vx sent
|
|
207
|
+
// one to the configuration's task, or to `build` with a todo.
|
|
208
|
+
if (hasTarget(project, rest)) deps.push(`${m.name}#${named(project, rest)}`)
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
if (d && typeof d === 'object') {
|
|
212
|
+
const o = d as Record<string, unknown>
|
|
213
|
+
const t = typeof o.target === 'string' ? o.target : undefined
|
|
214
|
+
if (t === undefined) {
|
|
215
|
+
todos.push(`dependsOn ${JSON.stringify(d)} has no target — dropped`)
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
// `options: "forward"` hands the dependency this target's options as
|
|
219
|
+
// overrides (`createTaskOverrides`): a different command, which vx's
|
|
220
|
+
// one task per target cannot be. Nothing to hand on, nothing to say
|
|
221
|
+
// (cypress's atomized `e2e-ci` forwards its empty options).
|
|
222
|
+
if (o.options === 'forward' && forwarded !== undefined && Object.keys(forwarded).length > 0)
|
|
223
|
+
forwards.options = true
|
|
224
|
+
// `ignore` is Nx's default; only `forward` asks for something vx lacks.
|
|
225
|
+
if (o.params === 'forward') forwards.params = true
|
|
226
|
+
const raw = o.projects ?? (o.dependencies === true ? 'dependencies' : undefined)
|
|
227
|
+
// Nx reads a lone string as a one-entry list (`projects: "b"`); it
|
|
228
|
+
// was not representable here, and the edge dropped (item 1053).
|
|
229
|
+
const projects =
|
|
230
|
+
typeof raw === 'string' && raw !== 'self' && raw !== 'dependencies' ? [raw] : raw
|
|
231
|
+
if (projects === undefined || projects === 'self') {
|
|
232
|
+
if (ownTarget(t)) deps.push(named(self, t))
|
|
233
|
+
} else if (projects === 'dependencies') deps.push(`^${t}`)
|
|
234
|
+
else if (Array.isArray(projects) && projects.every((p) => typeof p === 'string')) {
|
|
235
|
+
// Patterns and tags (`lib-*`, `tag:lib`) name the graph's nodes;
|
|
236
|
+
// only an exact name that is no package is worth a line.
|
|
237
|
+
for (const node of matchProjects(projects)) {
|
|
238
|
+
const m = metaByNode.get(node)
|
|
239
|
+
if (m && hasTarget(node, t)) deps.push(`${m.name}#${named(node, t)}`)
|
|
240
|
+
}
|
|
241
|
+
// A pattern, or a word or directory Nx matches, is no name to report.
|
|
242
|
+
for (const p of projects) {
|
|
243
|
+
if (
|
|
244
|
+
/^!|^tag:|\*/.test(p) ||
|
|
245
|
+
metaByNode.has(p) ||
|
|
246
|
+
matchProjects([p]).some((n) => metaByNode.has(n))
|
|
247
|
+
)
|
|
248
|
+
continue
|
|
249
|
+
todos.push(
|
|
250
|
+
`dependsOn project ${JSON.stringify(p)} is not a workspace package — edge dropped`,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
} else todos.push(`dependsOn ${JSON.stringify(d)} not representable in vx`)
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
todos.push(`dependsOn ${JSON.stringify(d)} not representable in vx`)
|
|
257
|
+
}
|
|
258
|
+
if (forwards.options) {
|
|
259
|
+
todos.push(
|
|
260
|
+
'dependsOn `options: "forward"` is not supported — the dependency runs with its own ' +
|
|
261
|
+
"options, not this target's",
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
if (forwards.params) {
|
|
265
|
+
todos.push(
|
|
266
|
+
'dependsOn `params: "forward"` is not supported — forward args via `vx run … -- args` instead',
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
// One edge spelled twice (`ui:gen` and `{ projects: ["ui"], target: "gen" }`)
|
|
270
|
+
// is one dependency; the written config listed it twice.
|
|
271
|
+
return [...new Set(deps)]
|
|
272
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Which `.env` files Nx loads for a task: `getEnvPathsForTask`
|
|
2
|
+
// (tasks-runner/task-env-paths.ts) and `getOwnerTargetForTask`
|
|
3
|
+
// (task-env.ts), the same in Nx 22.7 and 23.2. The project's own files come
|
|
4
|
+
// before the workspace root's, the most specific name first
|
|
5
|
+
// (`.env.build.production.local` … `.env`), and the first file to define a
|
|
6
|
+
// name wins. The mapper keeps the ones that EXIST, from one directory
|
|
7
|
+
// listing per project per run, so a task with none costs nothing at run
|
|
8
|
+
// time; the bins load them (`nx-dotenv.cjs`).
|
|
9
|
+
|
|
10
|
+
import { readdir } from 'node:fs/promises'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
|
|
13
|
+
/** A target's `metadata`, as far as the owner-target rule reads it. */
|
|
14
|
+
interface TargetMeta {
|
|
15
|
+
readonly metadata?: { readonly nonAtomizedTarget?: unknown }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `.env` file names per directory (workspace-relative, `.` for the root). */
|
|
19
|
+
export type DotenvListing = ReadonlyMap<string, ReadonlySet<string>>
|
|
20
|
+
|
|
21
|
+
function variants(identifier: string, root: string | undefined): string[] {
|
|
22
|
+
const p = root ? `${root}/` : ''
|
|
23
|
+
if (identifier) {
|
|
24
|
+
return [
|
|
25
|
+
`${p}.env.${identifier}.local`,
|
|
26
|
+
`${p}.env.${identifier}`,
|
|
27
|
+
`${p}.${identifier}.local.env`,
|
|
28
|
+
`${p}.${identifier}.env`,
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
return [`${p}.env.local`, `${p}.local.env`, `${p}.env`]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Nx's candidate list, workspace-relative, in load order. */
|
|
35
|
+
export function dotenvCandidates(
|
|
36
|
+
projectRoot: string,
|
|
37
|
+
target: string,
|
|
38
|
+
configuration: string | undefined,
|
|
39
|
+
nonAtomizedTarget: string | undefined,
|
|
40
|
+
): string[] {
|
|
41
|
+
const ids: string[] = []
|
|
42
|
+
if (configuration) {
|
|
43
|
+
ids.push(`${target}.${configuration}`)
|
|
44
|
+
if (nonAtomizedTarget) ids.push(`${nonAtomizedTarget}.${configuration}`)
|
|
45
|
+
ids.push(configuration)
|
|
46
|
+
}
|
|
47
|
+
ids.push(target)
|
|
48
|
+
if (nonAtomizedTarget) ids.push(nonAtomizedTarget)
|
|
49
|
+
ids.push('')
|
|
50
|
+
return [
|
|
51
|
+
...ids.flatMap((id) => variants(id, projectRoot)),
|
|
52
|
+
...ids.flatMap((id) => variants(id, undefined)),
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The target and parent whose names a task's `.env` files carry, as Nx's
|
|
58
|
+
* `getOwnerTargetForTask` finds them: the first member of the task's
|
|
59
|
+
* `targetGroups` group that has a `nonAtomizedTarget`, with that parent.
|
|
60
|
+
* An atomized `e2e-ci--src/a.cy.ts` loads `.env.e2e-ci` and `.env.e2e`
|
|
61
|
+
* (cypress puts the metadata on `e2e-ci`); read as its own name and the
|
|
62
|
+
* metadata of the task itself, it loaded neither.
|
|
63
|
+
*/
|
|
64
|
+
export function ownerTargetOf(
|
|
65
|
+
target: string,
|
|
66
|
+
targets: Readonly<Record<string, TargetMeta>>,
|
|
67
|
+
targetGroups: unknown,
|
|
68
|
+
): readonly [string, string | undefined] {
|
|
69
|
+
if (typeof targetGroups !== 'object' || targetGroups === null) return [target, undefined]
|
|
70
|
+
for (const group of Object.values(targetGroups as Record<string, unknown>)) {
|
|
71
|
+
if (!Array.isArray(group) || !group.includes(target)) continue
|
|
72
|
+
for (const t of group) {
|
|
73
|
+
const parent = typeof t === 'string' ? targets[t]?.metadata?.nonAtomizedTarget : undefined
|
|
74
|
+
if (typeof parent === 'string' && parent.length > 0) return [t as string, parent]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [target, undefined]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Could `name` be one of Nx's `.env` variants? */
|
|
81
|
+
function isDotenvName(name: string): boolean {
|
|
82
|
+
return name.startsWith('.env') || (name.startsWith('.') && name.endsWith('.env'))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One listing of each directory, the `.env`-shaped names only. */
|
|
86
|
+
export async function listDotenv(root: string, dirs: readonly string[]): Promise<DotenvListing> {
|
|
87
|
+
const unique = [...new Set(['.', ...dirs])]
|
|
88
|
+
const lists = await Promise.all(
|
|
89
|
+
unique.map((d) =>
|
|
90
|
+
readdir(path.join(root, d)).then(
|
|
91
|
+
(names) => new Set(names.filter(isDotenvName)),
|
|
92
|
+
() => new Set<string>(),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
return new Map(unique.map((d, i) => [d, lists[i]!]))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The candidates that exist, workspace-relative, in load order. */
|
|
100
|
+
export function existingDotenv(candidates: readonly string[], listing: DotenvListing): string[] {
|
|
101
|
+
return candidates.filter((c) => {
|
|
102
|
+
const slash = c.lastIndexOf('/')
|
|
103
|
+
const dir = slash === -1 ? '.' : c.slice(0, slash)
|
|
104
|
+
return listing.get(dir === '' ? '.' : dir)?.has(c.slice(slash + 1)) === true
|
|
105
|
+
})
|
|
106
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// An Nx target's `inputs` as vx's cache inputs. Named inputs expand from
|
|
2
|
+
// the project's scope (nx.json's merged under its own); a path's tokens
|
|
3
|
+
// interpolate as Nx's do, and it is a project glob when it lands in the
|
|
4
|
+
// project, else a workspace one; `{ env }` and
|
|
5
|
+
// `{ runtime }` map to their vx twins, `{ json }` to its whole file; `^x` is recorded for the mapper to
|
|
6
|
+
// resolve over the project graph (nx-upstream.ts). What vx folds through `dependsOn` already
|
|
7
|
+
// (`dependentTasksOutputFiles`) is nothing; `externalDependencies` is a todo saying
|
|
8
|
+
// so. Extracted from `buildTask` in item 606.
|
|
9
|
+
|
|
10
|
+
import { nxWorkspacePath, underProject } from './nx-outputs.js'
|
|
11
|
+
import { minimatchToVx } from '../glob-grammar.js'
|
|
12
|
+
import { shellQuote } from '../nx-command.js'
|
|
13
|
+
|
|
14
|
+
export interface NxInputs {
|
|
15
|
+
readonly files: string[]
|
|
16
|
+
readonly wsFiles: string[]
|
|
17
|
+
readonly envNames: string[]
|
|
18
|
+
/**
|
|
19
|
+
* Nx's `{ runtime: "<cmd>" }` hashes the command's output, run at the
|
|
20
|
+
* WORKSPACE ROOT (`hash_runtime.rs`): `cache.inputs.workspaceRuntime`.
|
|
21
|
+
* It was mapped to `runtime`, which runs in the project dir, so
|
|
22
|
+
* `cat tools/version.txt` failed the run and a command that runs in both
|
|
23
|
+
* hashed a different fact (item 913). It was once reported "not
|
|
24
|
+
* representable in vx" (walked the Nx path, 2026-09-20).
|
|
25
|
+
*/
|
|
26
|
+
readonly runtimeCmds: string[]
|
|
27
|
+
/** `^name` / `{ input, dependencies | projects }`: whose `name` to fold. */
|
|
28
|
+
readonly upstream: Array<{ readonly name: string; readonly of: 'deps' | readonly string[] }>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function emptyNxInputs(): NxInputs {
|
|
32
|
+
return { files: [], wsFiles: [], envNames: [], runtimeCmds: [], upstream: [] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Expands `entries` into `into`; a gap is a line in `todos`. */
|
|
36
|
+
export function expandNxInputs(
|
|
37
|
+
entries: readonly unknown[],
|
|
38
|
+
named: Readonly<Record<string, unknown[]>>,
|
|
39
|
+
at: {
|
|
40
|
+
readonly rel: string
|
|
41
|
+
readonly name: string
|
|
42
|
+
readonly outputs?: readonly string[]
|
|
43
|
+
/** The root package.json's dependency names: every task's key holds them. */
|
|
44
|
+
readonly rootDeps?: ReadonlySet<string>
|
|
45
|
+
},
|
|
46
|
+
into: NxInputs,
|
|
47
|
+
todos: string[],
|
|
48
|
+
): void {
|
|
49
|
+
const expand = (entry: unknown, seen: Set<string>): void => {
|
|
50
|
+
if (typeof entry === 'string') {
|
|
51
|
+
let s = entry
|
|
52
|
+
let neg = ''
|
|
53
|
+
if (s.startsWith('!')) {
|
|
54
|
+
neg = '!'
|
|
55
|
+
s = s.slice(1)
|
|
56
|
+
}
|
|
57
|
+
if (s.startsWith('^')) {
|
|
58
|
+
// vx folds each dependency's key through the dependsOn edges, so
|
|
59
|
+
// taking a dependency input out (`!^prod`) has nothing to remove.
|
|
60
|
+
if (neg !== '') return
|
|
61
|
+
into.upstream.push({ name: s.slice(1), of: 'deps' })
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (s.includes('{')) {
|
|
65
|
+
// A token anywhere, as Nx interpolates it (item 912).
|
|
66
|
+
const p = nxWorkspacePath(s, at.rel, at.name)
|
|
67
|
+
if (p === null) {
|
|
68
|
+
todos.push(`input ${JSON.stringify(entry)} uses a token vx does not support`)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (neg === '' && at.outputs?.some((o) => p === o || p.startsWith(`${o}/`))) {
|
|
72
|
+
todos.push(
|
|
73
|
+
`input ${JSON.stringify(entry)} is an output of the project's own targets: git does not ` +
|
|
74
|
+
'list it, so vx cannot key on it — dropped; the task that writes it keys its dependants through dependsOn',
|
|
75
|
+
)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
const g = minimatchToVx(p, neg !== '')
|
|
79
|
+
if (g === null) {
|
|
80
|
+
todos.push(`input ${JSON.stringify(entry)}: glob syntax vx cannot take — map manually`)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
const own = underProject(g, at.rel)
|
|
84
|
+
if (own === null) into.wsFiles.push(neg + g)
|
|
85
|
+
else into.files.push(neg + own)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
// Bare string = named-input reference.
|
|
89
|
+
const members = named[s]
|
|
90
|
+
if (members === undefined) {
|
|
91
|
+
// Over-keyed, never under: an empty input list keyed a cached task
|
|
92
|
+
// on its config alone, a stale hit after every source edit (an
|
|
93
|
+
// nx.json `extends` preset not installed where the snapshot is read).
|
|
94
|
+
if (neg === '') into.files.push('**/*')
|
|
95
|
+
todos.push(
|
|
96
|
+
`named input ${JSON.stringify(s)} not found in nx.json or the project — keyed on the whole project (\`**/*\`) until its globs are declared`,
|
|
97
|
+
)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
if (seen.has(s)) return
|
|
101
|
+
seen.add(s)
|
|
102
|
+
for (const e of members) expand(e, seen)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (entry && typeof entry === 'object') {
|
|
106
|
+
const o = entry as Record<string, unknown>
|
|
107
|
+
if (typeof o.env === 'string') {
|
|
108
|
+
into.envNames.push(o.env)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (typeof o.runtime === 'string') {
|
|
112
|
+
into.runtimeCmds.push(o.runtime)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (typeof o.fileset === 'string' && o.includeIgnored === true) {
|
|
116
|
+
ignored(entry, o.fileset, o.dependencies === true)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (typeof o.fileset === 'string') {
|
|
120
|
+
// `dependencies: true` is each dependency's fileset, as `^{projectRoot}/…`
|
|
121
|
+
// is. Expanded as the project's own, a dependency's edit re-keyed
|
|
122
|
+
// nothing here (nx-examples' inferred `typecheck` carries both).
|
|
123
|
+
if (o.dependencies === true) into.upstream.push({ name: o.fileset, of: 'deps' })
|
|
124
|
+
else expand(o.fileset, seen)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (typeof o.json === 'string') {
|
|
128
|
+
// `{ json, fields }` hashes only those fields; the whole file is a
|
|
129
|
+
// superset. Reported and dropped, a `compilerOptions` edit to the
|
|
130
|
+
// root tsconfig.json was a hit where Nx re-ran (@nx/vitest infers
|
|
131
|
+
// it, 2026-09-28).
|
|
132
|
+
expand(o.json.includes('{') ? o.json : `{workspaceRoot}/${o.json}`, seen)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (o.externalDependencies !== undefined) {
|
|
136
|
+
// Not the project's package.json: Nx's inferred targets name root
|
|
137
|
+
// devDependencies (eslint, vitest), which only the lockfile keys.
|
|
138
|
+
// One the root declares is keyed already, with or without a
|
|
139
|
+
// lockfile plugin (it keys the root's dependencies): nothing to
|
|
140
|
+
// say. The todo sat on 32 tasks of analogjs for `eslint`.
|
|
141
|
+
const names = Array.isArray(o.externalDependencies) ? o.externalDependencies : []
|
|
142
|
+
if (names.length > 0 && names.every((n) => typeof n === 'string' && at.rootDeps?.has(n)))
|
|
143
|
+
return
|
|
144
|
+
todos.push(
|
|
145
|
+
`input {externalDependencies: ${JSON.stringify(o.externalDependencies)}}: vx keys ` +
|
|
146
|
+
'every task on the lockfile (the whole file, or with a @vzn/vx-lockfile plugin the ' +
|
|
147
|
+
"project's and the root's dependencies) — safe to drop unless only another project installs one",
|
|
148
|
+
)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
// Nx hashes the outputs of the tasks this one depends on; vx folds
|
|
152
|
+
// those tasks' keys (their inputs, transitively) through `dependsOn`,
|
|
153
|
+
// and an output follows from its inputs. Nothing to map, and the todo
|
|
154
|
+
// it was sat on 881 tasks of three real Nx repos (G-49).
|
|
155
|
+
if (o.dependentTasksOutputFiles !== undefined) return
|
|
156
|
+
// `{ workingDirectory }` hashes where Nx was started (`cwd:relative`
|
|
157
|
+
// in its plan). A vx task runs in its project dir from wherever vx
|
|
158
|
+
// is started, so the fact is the same for every run: nothing to key.
|
|
159
|
+
if (o.workingDirectory === 'relative' || o.workingDirectory === 'absolute') return
|
|
160
|
+
if (typeof o.input === 'string') {
|
|
161
|
+
// Nx 23 still reads the pre-17 spellings `projects: "dependencies"`
|
|
162
|
+
// (`^input`) and `projects: "self"` (the project's own); taken as
|
|
163
|
+
// project names, each was a todo and the input was dropped.
|
|
164
|
+
if (o.dependencies === true || o.projects === 'dependencies') {
|
|
165
|
+
into.upstream.push({ name: o.input, of: 'deps' })
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
if (o.projects !== undefined && o.projects !== 'self') {
|
|
169
|
+
const of = typeof o.projects === 'string' ? [o.projects] : o.projects
|
|
170
|
+
if (!Array.isArray(of) || !of.every((p) => typeof p === 'string')) {
|
|
171
|
+
todos.push(`input ${JSON.stringify(entry)} not representable in vx`)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
into.upstream.push({ name: o.input, of: of as string[] })
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
expand(o.input, seen)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
todos.push(`input ${JSON.stringify(entry)} not representable in vx`)
|
|
182
|
+
}
|
|
183
|
+
// `{ fileset, includeIgnored: true }` (Nx 23) hashes the path from disk,
|
|
184
|
+
// gitignored or generated, a missing one included. vx's globs see only
|
|
185
|
+
// what git lists: mapped as one, a gitignored literal failed the task
|
|
186
|
+
// before it ran. A literal path is read by a probe at the workspace root
|
|
187
|
+
// instead, its exit telling a missing file from an empty one.
|
|
188
|
+
const ignored = (entry: unknown, fileset: string, deps: boolean): void => {
|
|
189
|
+
const neg = fileset.startsWith('!')
|
|
190
|
+
const p = nxWorkspacePath(neg ? fileset.slice(1) : fileset, at.rel, at.name)
|
|
191
|
+
if (deps || p === null || /[*?[{]/.test(p)) {
|
|
192
|
+
todos.push(
|
|
193
|
+
`input ${JSON.stringify(entry)}: vx keys only the files git lists, so a gitignored ` +
|
|
194
|
+
'match is not in the key — read it with a cache.inputs.workspaceRuntime probe',
|
|
195
|
+
)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
// A negation filters only other includeIgnored matches, and a literal
|
|
199
|
+
// reads no others.
|
|
200
|
+
if (!neg) into.runtimeCmds.push(`cat -- ${shellQuote(p)} 2>/dev/null; echo "$?"`)
|
|
201
|
+
}
|
|
202
|
+
for (const entry of entries) expand(entry, new Set())
|
|
203
|
+
// Nx matches a project fileset with no positive glob against every project
|
|
204
|
+
// file, the negations taken out: nx-recipes' `noMarkdown` is
|
|
205
|
+
// `["!{projectRoot}/**/*.md"]`, and a `.tsx` edit re-runs its build while a
|
|
206
|
+
// `.md` edit hits (nx 17.1.3, 2026-10-01). Mapped as written, core refused
|
|
207
|
+
// the negation-only list and no task of the workspace ran.
|
|
208
|
+
if (into.files.length > 0 && into.files.every((f) => f.startsWith('!')))
|
|
209
|
+
into.files.unshift('**/*')
|
|
210
|
+
}
|