@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.
@@ -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
+ }