@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/src/nx/index.ts CHANGED
@@ -23,6 +23,7 @@ import { stat } from 'node:fs/promises'
23
23
  import { availableParallelism } from 'node:os'
24
24
  import path from 'node:path'
25
25
  import {
26
+ type DiscoveredProject,
26
27
  refuseUnknownOptions,
27
28
  type PluginOptionKinds,
28
29
  type GeneratedProject,
@@ -30,6 +31,7 @@ import {
30
31
  UserError,
31
32
  type VxPlugin,
32
33
  } from '@vzn/vx'
34
+ import type { PackageMeta } from '../packages.js'
33
35
  import { type AdoptionRun, adoptionPlugin } from '../adoption-plugin.js'
34
36
  import { collectGaps } from '../plugin-gaps.js'
35
37
  import {
@@ -43,6 +45,7 @@ import {
43
45
  } from './nx-map.js'
44
46
  import { exportGraph } from './export-graph.js'
45
47
  import { lernaJsonText } from './lerna.js'
48
+ import { nxDependencyRules } from './nx-boundaries.js'
46
49
  export type {
47
50
  NxExecutors,
48
51
  NxExecutorTarget,
@@ -52,6 +55,7 @@ export type {
52
55
  import { listDotenv } from './nx-dotenv.js'
53
56
  import { trackedKinds } from '../tracked-outputs.js'
54
57
  import { relPosix } from '../paths.js'
58
+ import { memberProjects, withManifests } from '../manifests.js'
55
59
  import type { AdoptionMapping } from '../mapping-cache.js'
56
60
  import { yarnrcText } from '../script-command.js'
57
61
 
@@ -119,10 +123,12 @@ export function nx(options: NxPluginOptions = {}): VxPlugin {
119
123
  cacheDir: string,
120
124
  projects: readonly ProjectMeta[],
121
125
  changes?: WorktreeChanges,
126
+ members: readonly DiscoveredProject[] = [],
122
127
  ) => {
123
128
  if (graphFor !== projects) {
124
129
  graphFor = projects
125
- graph = loadGraph(root, cacheDir, projects, options.graph, changes)
130
+ const also = members.map((m) => ({ name: m.name ?? '', dir: m.dir, configPath: null }))
131
+ graph = loadGraph(root, cacheDir, [...projects, ...also], options.graph, changes)
126
132
  }
127
133
  return graph!
128
134
  }
@@ -142,33 +148,45 @@ export function nx(options: NxPluginOptions = {}): VxPlugin {
142
148
  {
143
149
  async config(workspace, ctx) {
144
150
  const root = options.root ?? ctx.workspaceRoot
145
- if (workspace.affectedBase === undefined) {
151
+ if (workspace.run?.affectedBase === undefined) {
146
152
  const base = await nxBase(root)
147
- if (base !== undefined) workspace.affectedBase = base
153
+ if (base !== undefined) workspace.run = { ...workspace.run, affectedBase: base }
148
154
  }
149
- if (workspace.concurrency === undefined) {
155
+ if (workspace.run?.concurrency === undefined) {
150
156
  const parallel = await nxParallel(root)
151
- if (parallel !== undefined) workspace.concurrency = parallel
157
+ if (parallel !== undefined) workspace.run = { ...workspace.run, concurrency: parallel }
152
158
  }
153
- if (workspace.cacheRetention === undefined) {
159
+ if (workspace.run?.rules?.dependencies === undefined) {
160
+ const dependencies = await nxDependencyRules(root)
161
+ if (dependencies !== undefined && dependencies.length > 0)
162
+ workspace.run = {
163
+ ...workspace.run,
164
+ rules: { ...workspace.run?.rules, dependencies },
165
+ }
166
+ }
167
+ if (workspace.cache?.retention === undefined) {
154
168
  const maxSize = await nxMaxCacheSize(root)
155
- if (maxSize !== undefined) workspace.cacheRetention = { maxSize }
169
+ if (maxSize !== undefined)
170
+ workspace.cache = { ...workspace.cache, retention: { maxSize } }
156
171
  }
157
172
  },
158
- // An integrated Nx repo keeps projects out of the package manager's
159
- // list (analogjs: `project.json` libraries no glob names), and their
173
+ // The package manager's members are projects, each with no vx.config
174
+ // written. An integrated Nx repo keeps projects out of that list
175
+ // (analogjs: `project.json` libraries no glob names), and their
160
176
  // targets had nowhere to attach: 1 of 21 `build` tasks ran. Each
161
- // graph node with targets at a directory core did not find is named
162
- // a project, by its package.json name or else its Nx name; a name a
177
+ // graph node with targets at a directory neither found is named a
178
+ // project, by its package.json name or else its Nx name; a name a
163
179
  // project already holds stays unattached, and the mapping says so.
164
180
  async discover(ctx) {
165
181
  const root = options.root ?? ctx.workspaceRoot
182
+ const members = await memberProjects(ctx.workspaceRoot)
166
183
  // Core's status is the workspace's: a root elsewhere asks git itself.
167
184
  const changes = options.root === undefined ? () => ctx.worktreeChanges() : undefined
168
- const { graph } = await graphOf(root, ctx.cacheDir, ctx.projects, changes)
169
- const dirs = new Set(ctx.projects.map((m) => path.resolve(m.dir)))
170
- const names = new Set(ctx.projects.map((m) => m.name))
171
- const named: Array<{ dir: string; name: string }> = []
185
+ const { graph } = await graphOf(root, ctx.cacheDir, ctx.projects, changes, members)
186
+ const found = [...ctx.projects, ...members]
187
+ const dirs = new Set(found.map((m) => path.resolve(m.dir)))
188
+ const names = new Set(found.map((m) => m.name))
189
+ const named = members.map((m) => ({ dir: path.resolve(m.dir), name: m.name! }))
172
190
  for (const [nodeName, node] of Object.entries(graph.nodes)) {
173
191
  if (node?.data?.targets === undefined) continue
174
192
  const dir = path.resolve(root, node.data.root ?? '')
@@ -190,12 +208,45 @@ export function nx(options: NxPluginOptions = {}): VxPlugin {
190
208
  dirs.add(dir)
191
209
  named.push({ dir, name })
192
210
  }
193
- return named
211
+ const entries = withGraphEdges(root, graph, dirs, named)
212
+ const covered = new Set(entries.map((e) => e.dir))
213
+ return [...entries, ...named.filter((n) => !covered.has(n.dir))]
194
214
  },
195
215
  },
196
216
  )
197
217
  }
198
218
 
219
+ /**
220
+ * Every project the graph and the workspace share, with its Nx graph edges
221
+ * (package deps, `implicitDependencies`, imports) as `deps`: nx() is an Nx
222
+ * repo's edge source, so `^task` follows Nx's graph with no lockfile plugin.
223
+ */
224
+ function withGraphEdges(
225
+ root: string,
226
+ graph: NxGraph,
227
+ dirs: ReadonlySet<string>,
228
+ named: ReadonlyArray<{ dir: string; name: string }>,
229
+ ): DiscoveredProject[] {
230
+ const dirOf = new Map<string, string>()
231
+ for (const [nodeName, node] of Object.entries(graph.nodes)) {
232
+ const dir = path.resolve(root, node?.data?.root ?? '')
233
+ if (dirs.has(dir)) dirOf.set(nodeName, dir)
234
+ }
235
+ const nameOf = new Map(named.map((n) => [n.dir, n.name]))
236
+ const edges = (graph.dependencies ?? {}) as Record<string, ReadonlyArray<{ target?: unknown }>>
237
+ const out: DiscoveredProject[] = []
238
+ for (const [nodeName, dir] of dirOf) {
239
+ const deps = new Set<string>()
240
+ for (const e of edges[nodeName] ?? []) {
241
+ const to = typeof e?.target === 'string' ? dirOf.get(e.target) : undefined
242
+ if (to !== undefined && to !== dir) deps.add(to)
243
+ }
244
+ const name = nameOf.get(dir)
245
+ out.push({ dir, ...(name === undefined ? {} : { name }), deps: [...deps].sort() })
246
+ }
247
+ return out
248
+ }
249
+
199
250
  function isWithin(root: string, dir: string): boolean {
200
251
  const rel = path.relative(root, dir)
201
252
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
@@ -268,10 +319,11 @@ interface LoadedGraph {
268
319
 
269
320
  async function mapAll(
270
321
  root: string,
271
- metas: readonly ProjectMeta[],
322
+ projects: readonly ProjectMeta[],
272
323
  loaded: LoadedGraph,
273
324
  executors: NxExecutors | undefined,
274
325
  ): Promise<AdoptionRun> {
326
+ const metas = await withManifests(projects)
275
327
  const { text, graph, notes } = loaded
276
328
  const reads = await nxReads(root, metas, text, graph, notes)
277
329
  // The mapping is cached on what it read; a translator is code it ran.
@@ -304,7 +356,7 @@ const textOf = (file: string): Promise<string> =>
304
356
  */
305
357
  async function nxReads(
306
358
  root: string,
307
- metas: readonly ProjectMeta[],
359
+ metas: readonly PackageMeta[],
308
360
  graphText: string,
309
361
  graph: NxGraph,
310
362
  notes: readonly string[],
@@ -346,7 +398,7 @@ async function nxReads(
346
398
 
347
399
  async function index(
348
400
  root: string,
349
- metas: readonly ProjectMeta[],
401
+ metas: readonly PackageMeta[],
350
402
  graph: NxGraph,
351
403
  loadNotes: readonly string[],
352
404
  trackedFiles: () => Promise<readonly string[] | null>,
@@ -535,6 +587,9 @@ async function graphInputKey(
535
587
  // and stays in.
536
588
  const nxCache = (p: string): boolean =>
537
589
  p.startsWith('.nx/cache/') || p.startsWith('.nx/workspace-data/')
590
+ // vx's own directory (the lock `vx lock` rewrites, the workspace file):
591
+ // Nx reads nothing there, and under a root project each lock re-exported.
592
+ const vxDir = (p: string): boolean => p.startsWith('.vx/')
538
593
  // What can move the graph: a file under a project root (its sources, the
539
594
  // configs a plugin infers targets from), or a root file Nx reads. A task's
540
595
  // stray write at the root (a report, a log) is not, and counting it
@@ -546,7 +601,9 @@ async function graphInputKey(
546
601
  p.endsWith('/project.json') ||
547
602
  roots.some((r) => r === '' || p.startsWith(`${r}/`)) ||
548
603
  (!p.includes('/') && ROOT_GRAPH_FILE.test(p))
549
- const listed = [...new Set(changed.filter((p) => !inCache(p) && !nxCache(p) && graphFile(p)))]
604
+ const listed = [
605
+ ...new Set(changed.filter((p) => !inCache(p) && !nxCache(p) && !vxDir(p) && graphFile(p))),
606
+ ]
550
607
  // A manifest needs no read of its own: git lists it when it is edited, and
551
608
  // HEAD moves when an edit is committed (reading all 2,000 cost 50 ms at
552
609
  // 1,000 projects). nx.json's chain does: a base may sit in node_modules,
@@ -0,0 +1,191 @@
1
+ import path from 'node:path'
2
+
3
+ /** `run.rules.dependencies`' entry, as core takes it. */
4
+ interface DependencyRule {
5
+ from: string
6
+ allow?: string[]
7
+ deny?: string[]
8
+ }
9
+
10
+ const RULE_NAMES = ['@nx/enforce-module-boundaries', '@nrwl/nx/enforce-module-boundaries']
11
+ const FLAT = ['eslint.config.mjs', 'eslint.config.js', 'eslint.config.cjs', 'eslint.config.ts']
12
+ const LEGACY = ['.eslintrc.json', '.eslintrc']
13
+
14
+ /**
15
+ * The root ESLint config's `@nx/enforce-module-boundaries` `depConstraints`,
16
+ * as vx's lock-time dependency rules. A flat config is read, never run:
17
+ * importing it loads ESLint's plugins on every vx run, for a rule only
18
+ * `vx lock` checks. So the constraints must be a literal array there; a
19
+ * spread or a variable maps nothing. Undefined when no rule is found.
20
+ */
21
+ export async function nxDependencyRules(root: string): Promise<DependencyRule[] | undefined> {
22
+ for (const name of [...FLAT, ...LEGACY]) {
23
+ const file = Bun.file(path.join(root, name))
24
+ if (!(await file.exists())) continue
25
+ const text = await file.text()
26
+ const constraints = LEGACY.includes(name) ? fromJson(text) : fromSource(text)
27
+ return constraints === undefined ? undefined : mapConstraints(constraints)
28
+ }
29
+ return undefined
30
+ }
31
+
32
+ /** `rules` and each `overrides[].rules` of a JSON config, first rule found. */
33
+ function fromJson(text: string): unknown[] | undefined {
34
+ let json: unknown
35
+ try {
36
+ json = Bun.JSONC.parse(text)
37
+ } catch {
38
+ return undefined
39
+ }
40
+ const configs = [json, ...((json as { overrides?: unknown[] })?.overrides ?? [])]
41
+ for (const c of configs) {
42
+ const rules = (c as { rules?: Record<string, unknown> } | null)?.rules
43
+ for (const r of RULE_NAMES) {
44
+ const entry = rules?.[r]
45
+ const options = Array.isArray(entry) ? entry[1] : undefined
46
+ const dc = (options as { depConstraints?: unknown } | undefined)?.depConstraints
47
+ if (Array.isArray(dc)) return dc
48
+ }
49
+ }
50
+ return undefined
51
+ }
52
+
53
+ /** The literal after the rule's `depConstraints:` in JS source. */
54
+ function fromSource(text: string): unknown[] | undefined {
55
+ for (const r of RULE_NAMES) {
56
+ const at = text.indexOf(r)
57
+ if (at === -1) continue
58
+ const m = /depConstraints['"]?\s*:\s*\[/.exec(text.slice(at))
59
+ if (m === null) return undefined
60
+ try {
61
+ const v = new Literal(text, at + m.index + m[0].length - 1).value()
62
+ return Array.isArray(v) ? v : undefined
63
+ } catch {
64
+ return undefined
65
+ }
66
+ }
67
+ return undefined
68
+ }
69
+
70
+ /**
71
+ * Nx's constraint as a vx rule: `sourceTag` is `from`,
72
+ * `onlyDependOnLibsWithTags` is `allow`, `notDependOnLibsWithTags` is
73
+ * `deny`; `*` means any project. A constraint vx cannot say is left out:
74
+ * `allSourceTags` (every tag at once), a `/regex/` tag, or one that bans
75
+ * only external imports.
76
+ */
77
+ function mapConstraints(constraints: unknown[]): DependencyRule[] {
78
+ const out: DependencyRule[] = []
79
+ const sel = (t: unknown) => (t === '*' ? '*' : `tag:${String(t)}`)
80
+ const tags = (v: unknown): string[] | undefined =>
81
+ Array.isArray(v) && v.every((t) => typeof t === 'string' && t !== '' && !t.startsWith('/'))
82
+ ? v.map(sel)
83
+ : undefined
84
+ for (const c of constraints) {
85
+ const { sourceTag, onlyDependOnLibsWithTags, notDependOnLibsWithTags } = (c ?? {}) as Record<
86
+ string,
87
+ unknown
88
+ >
89
+ if (typeof sourceTag !== 'string' || sourceTag === '' || sourceTag.startsWith('/')) continue
90
+ const allow = tags(onlyDependOnLibsWithTags)
91
+ const deny = tags(notDependOnLibsWithTags)
92
+ if (allow === undefined && deny === undefined) continue
93
+ out.push({
94
+ from: sel(sourceTag),
95
+ ...(allow !== undefined ? { allow } : {}),
96
+ ...(deny !== undefined ? { deny } : {}),
97
+ })
98
+ }
99
+ return out
100
+ }
101
+
102
+ /** A JS literal: arrays, objects, strings, numbers, booleans, null. Anything else throws. */
103
+ class Literal {
104
+ constructor(
105
+ private readonly s: string,
106
+ private i: number,
107
+ ) {}
108
+
109
+ value(): unknown {
110
+ this.skip()
111
+ const c = this.s[this.i]
112
+ if (c === '[') {
113
+ this.i++
114
+ const out: unknown[] = []
115
+ while (!this.eat(']')) {
116
+ out.push(this.value())
117
+ if (!this.eat(',')) {
118
+ this.expect(']')
119
+ break
120
+ }
121
+ }
122
+ return out
123
+ }
124
+ if (c === '{') {
125
+ this.i++
126
+ const out: Record<string, unknown> = {}
127
+ while (!this.eat('}')) {
128
+ const key = this.key()
129
+ this.expect(':')
130
+ out[key] = this.value()
131
+ if (!this.eat(',')) {
132
+ this.expect('}')
133
+ break
134
+ }
135
+ }
136
+ return out
137
+ }
138
+ if (c === "'" || c === '"' || c === '`') return this.string(c)
139
+ const m = this.match(/true|false|null|-?\d+(?:\.\d+)?/y)
140
+ if (m === null) throw new Error('not a literal')
141
+ return JSON.parse(m)
142
+ }
143
+
144
+ private key(): string {
145
+ this.skip()
146
+ const c = this.s[this.i]
147
+ if (c === "'" || c === '"') return this.string(c)
148
+ const m = this.match(/[A-Za-z_$][\w$]*/y)
149
+ if (m === null) throw new Error('not a key')
150
+ return m
151
+ }
152
+
153
+ private string(q: string): string {
154
+ let out = ''
155
+ for (this.i++; this.i < this.s.length; this.i++) {
156
+ const c = this.s[this.i]!
157
+ if (c === q) {
158
+ this.i++
159
+ return out
160
+ }
161
+ if (c === '\\') out += this.s[++this.i]
162
+ else if (q === '`' && c === '$' && this.s[this.i + 1] === '{') throw new Error('template')
163
+ else out += c
164
+ }
165
+ throw new Error('unterminated')
166
+ }
167
+
168
+ private skip(): void {
169
+ while (this.match(/\s+|\/\/[^\n]*|\/\*[\s\S]*?\*\//y) !== null);
170
+ }
171
+
172
+ /** The sticky `re`'s match at the cursor, consumed; null when none. */
173
+ private match(re: RegExp): string | null {
174
+ re.lastIndex = this.i
175
+ const m = re.exec(this.s)
176
+ if (m === null || m[0] === '') return null
177
+ this.i += m[0].length
178
+ return m[0]
179
+ }
180
+
181
+ private eat(c: string): boolean {
182
+ this.skip()
183
+ if (this.s[this.i] !== c) return false
184
+ this.i++
185
+ return true
186
+ }
187
+
188
+ private expect(c: string): void {
189
+ if (!this.eat(c)) throw new Error(`expected ${c}`)
190
+ }
191
+ }
package/src/nx/nx-deps.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // vx-migrate itself wrote (walked the Nx path, 2026-09-20). Extracted from
7
7
  // `buildTask` in item 606; the rules are unchanged.
8
8
 
9
- import type { ProjectMeta } from '@vzn/vx'
9
+ import type { PackageMeta } from '../packages.js'
10
10
 
11
11
  /** The vx task an Nx `project:target:configuration` reaches, or null when the target lacks it. */
12
12
  export type TaskNameFor = (project: string, target: string, configuration: string) => string | null
@@ -16,7 +16,7 @@ export type TaskNameFor = (project: string, target: string, configuration: strin
16
16
  * target (`processTasksForSingleProject`), and says nothing otherwise:
17
17
  * nx-examples' `targetDefaults` give every `typecheck` a `codegen` that
18
18
  * one project declares. Passed through, core refused the whole run —
19
- * "depends on …#codegen but no such task is declared". Dropped here as
19
+ * "dependsOn …#codegen but no such task is declared". Dropped here as
20
20
  * Nx drops it, for this project and a named one alike.
21
21
  */
22
22
  type HasTarget = (project: string, target: string) => boolean
@@ -142,7 +142,7 @@ function expandTargetGlobs(
142
142
 
143
143
  export function mapNxDeps(
144
144
  raw: readonly unknown[],
145
- metaByNode: ReadonlyMap<string, ProjectMeta>,
145
+ metaByNode: ReadonlyMap<string, PackageMeta>,
146
146
  ownTarget: (name: string) => boolean,
147
147
  taskNameFor: TaskNameFor,
148
148
  hasTarget: HasTarget,
package/src/nx/nx-map.ts CHANGED
@@ -2,9 +2,10 @@
2
2
  // consumers: `bunx @vzn/vx-migrate --from nx` writes it to files, and the
3
3
  // `nx()` plugin runs it live. Static by design: targets that Nx plugins
4
4
  // infer at runtime are whatever the graph says. Named inputs expand from
5
- // nx.json when readable; the graph's dependency edges are ignored (vx
6
- // derives package edges from package.json manifests) except for one
7
- // report line counting edges with no manifest counterpart.
5
+ // nx.json when readable; the graph's dependency edges are ignored (vx's
6
+ // package edges come from a lockfile plugin, which installs what the
7
+ // manifests name) except for one report line counting edges with no
8
+ // manifest counterpart.
8
9
  //
9
10
  // Executors: `nx:run-commands`, a plain `command` and `nx:run-script` are
10
11
  // shell; `nx:noop` is a group; an executor the workspace translates
@@ -16,14 +17,10 @@
16
17
  // default configuration is folded into the base task.
17
18
 
18
19
  import path from 'node:path'
19
- import {
20
- buildPackageGraph,
21
- type GeneratedProject,
22
- type GeneratedTask,
23
- type ProjectMeta,
24
- UserError,
25
- } from '@vzn/vx'
20
+ import { type Duration, type GeneratedProject, type GeneratedTask, UserError } from '@vzn/vx'
21
+ import type { PackageMeta } from '../packages.js'
26
22
  import { withoutTakenBack } from '../glob-grammar.js'
23
+ import { manifestGraph, type ManifestGraph } from '../manifest-graph.js'
27
24
  import { mapRunCommands, shellQuote } from '../nx-command.js'
28
25
  import { scriptCommand, yarnPnp } from '../script-command.js'
29
26
  import {
@@ -251,8 +248,8 @@ export interface NxExecutorTarget {
251
248
  /** What a translated target runs as. The command runs in the project dir. */
252
249
  export interface NxExecutorTranslation {
253
250
  readonly command: string
254
- /** `exec.timeout`, in ms. */
255
- readonly timeout?: number
251
+ /** `exec.timeout`, a duration with its unit (`'10m'`). */
252
+ readonly timeout?: Duration
256
253
  /** Defined in the task's environment, as a run-commands `env` is. */
257
254
  readonly env?: Readonly<Record<string, string>>
258
255
  }
@@ -298,7 +295,7 @@ function normRel(p: string): string {
298
295
 
299
296
  export async function mapNxWorkspace(
300
297
  root: string,
301
- metas: readonly ProjectMeta[],
298
+ metas: readonly PackageMeta[],
302
299
  graph: NxGraph,
303
300
  opts: MapNxOptions,
304
301
  ): Promise<NxMapping> {
@@ -324,11 +321,11 @@ export async function mapNxWorkspace(
324
321
  syncTasks: new Map(),
325
322
  }
326
323
 
327
- const metaByRel = new Map<string, ProjectMeta>()
324
+ const metaByRel = new Map<string, PackageMeta>()
328
325
  for (const meta of metas) metaByRel.set(normRel(relPosix(root, meta.dir)), meta)
329
- const metaByNode = new Map<string, ProjectMeta>()
330
- const nodeByMeta = new Map<ProjectMeta, NxNode>()
331
- const nodeNameOf = new Map<ProjectMeta, string>()
326
+ const metaByNode = new Map<string, PackageMeta>()
327
+ const nodeByMeta = new Map<PackageMeta, NxNode>()
328
+ const nodeNameOf = new Map<PackageMeta, string>()
332
329
  for (const [nodeName, node] of Object.entries(nodeMap)) {
333
330
  const meta = metaByRel.get(normRel(node?.data?.root ?? ''))
334
331
  if (meta) {
@@ -343,16 +340,16 @@ export async function mapNxWorkspace(
343
340
  // workspace root, but Nx routinely defines root targets. Synthesize
344
341
  // a meta for any unmatched node whose root resolves inside the
345
342
  // workspace, so its targets migrate too.
346
- const allMetas: ProjectMeta[] = [...metas]
343
+ const allMetas: PackageMeta[] = [...metas]
347
344
  const taken = new Set(metas.map((m) => m.name))
348
345
  for (const [nodeName, node] of Object.entries(nodeMap)) {
349
346
  if (metaByNode.has(nodeName)) continue
350
347
  const relRoot = normRel(node?.data?.root ?? '')
351
348
  if (node?.data?.targets === undefined) continue
352
349
  const dir = relRoot === '' || relRoot === '.' ? root : path.join(root, relRoot)
353
- let pkg: ProjectMeta['packageJson'] = { name: nodeName }
350
+ let pkg: PackageMeta['packageJson'] = { name: nodeName }
354
351
  try {
355
- pkg = (await Bun.file(path.join(dir, 'package.json')).json()) as ProjectMeta['packageJson']
352
+ pkg = (await Bun.file(path.join(dir, 'package.json')).json()) as PackageMeta['packageJson']
356
353
  } catch {
357
354
  // No manifest at the node root — keep the synthetic one.
358
355
  }
@@ -362,7 +359,7 @@ export async function mapNxWorkspace(
362
359
  const name = [pkg.name, nodeName].find((n) => n && !taken.has(n))
363
360
  if (name === undefined) continue
364
361
  taken.add(name)
365
- const synthetic: ProjectMeta = {
362
+ const synthetic: PackageMeta = {
366
363
  name,
367
364
  dir,
368
365
  packageJson: pkg,
@@ -388,7 +385,7 @@ export async function mapNxWorkspace(
388
385
 
389
386
  // Once per project, not per task and variant: `path.relative` was a
390
387
  // fifth of the mapping at 1,000 projects (item 608).
391
- const relOf = new Map<ProjectMeta, string>()
388
+ const relOf = new Map<PackageMeta, string>()
392
389
  for (const meta of allMetas) {
393
390
  if (nodeByMeta.get(meta)?.data?.targets) relOf.set(meta, normRel(relPosix(root, meta.dir)))
394
391
  }
@@ -399,7 +396,7 @@ export async function mapNxWorkspace(
399
396
  ? null
400
397
  : await listDotenv(root, [...relOf.values()])
401
398
 
402
- const mapped: Array<{ meta: ProjectMeta; tasks: GeneratedTask[] }> = []
399
+ const mapped: Array<{ meta: PackageMeta; tasks: GeneratedTask[] }> = []
403
400
  let releasePublish = 0
404
401
  // A configuration variant's task (`build:production`) and what it runs.
405
402
  const configured = new WeakMap<GeneratedTask, string>()
@@ -521,11 +518,11 @@ export async function mapNxWorkspace(
521
518
  nxClosureMemo.set(from, (r = seen))
522
519
  return r
523
520
  }
524
- // What vx's `^` already reaches: a manifest path, direct or through
525
- // another project, orders `^x` and folds its key (item 931). Built once,
526
- // on the first `^` edge.
527
- let pkgGraph: ReturnType<typeof buildPackageGraph> | undefined
528
- const packageGraph = () => (pkgGraph ??= buildPackageGraph([...metas]))
521
+ // What vx's `^` already reaches: a manifest path (the lockfile plugin's
522
+ // edges), direct or through another project, orders `^x` and folds its
523
+ // key (item 931). Built once, on the first `^` edge.
524
+ let pkgGraph: ManifestGraph | undefined
525
+ const packageGraph = () => (pkgGraph ??= manifestGraph(metas))
529
526
  let reach: ((name: string) => ReadonlySet<string>) | undefined
530
527
  const reached = (name: string): ReadonlySet<string> => {
531
528
  if (reach === undefined) {
@@ -776,7 +773,7 @@ function allTargetNames(nodeMap: Readonly<Record<string, NxNode>>): string[] {
776
773
  }
777
774
 
778
775
  function buildTask(
779
- meta: ProjectMeta,
776
+ meta: PackageMeta,
780
777
  projectRel: string,
781
778
  scripts: Record<string, unknown>,
782
779
  projectName: string,
@@ -785,7 +782,7 @@ function buildTask(
785
782
  variant: Variant,
786
783
  nodeName: string,
787
784
  upstream: NxUpstream,
788
- metaByNode: ReadonlyMap<string, ProjectMeta>,
785
+ metaByNode: ReadonlyMap<string, PackageMeta>,
789
786
  nodeMap: Readonly<Record<string, NxNode>>,
790
787
  taskNameFor: TaskNameFor,
791
788
  opts: MapOpts,
@@ -874,9 +871,8 @@ function buildTask(
874
871
  const readyWhen = mapped?.readyWhen
875
872
  const persistent = readyWhen !== undefined || persistentTarget(target)
876
873
  // vx cleans outputs before a run and Nx never does. With git's file list
877
- // known, a wildcard-first output (`**/*.d.ts`) stays cached: each
878
- // committed file it reaches is taken back with `!` after mapping
879
- // (`spareTrackedOutputs`), and what the clean removes is what the task
874
+ // known, a wildcard-first output (`**/*.d.ts`) stays cached: core never
875
+ // cleans a committed file, so what the clean removes is what the task
880
876
  // writes (owner, 2026-10-03). Without it, the sources cannot be told apart.
881
877
  const wild =
882
878
  opts.tracked === undefined
@@ -1023,7 +1019,7 @@ interface MappedCommand {
1023
1019
  /** The `.env` files the command loads, relative to the project dir: key inputs. */
1024
1020
  readonly envInputs: readonly string[]
1025
1021
  /** A translated executor's `exec.timeout`. */
1026
- readonly timeout?: number
1022
+ readonly timeout?: Duration
1027
1023
  /** The Nx targets a leading `nx <target> <project>` ran: dependency edges (nx-command.ts). */
1028
1024
  readonly nxCalls?: readonly { readonly project: string; readonly target: string }[]
1029
1025
  }
@@ -1169,7 +1165,7 @@ function mapCommand(
1169
1165
  function translation(
1170
1166
  t: unknown,
1171
1167
  at: string,
1172
- ): { command: string; env: Record<string, unknown>; timeout: number | undefined } {
1168
+ ): { command: string; env: Record<string, unknown>; timeout: Duration | undefined } {
1173
1169
  if (!isRecord(t)) throw new UserError(`${at}: must return { command } or undefined`)
1174
1170
  for (const key of Object.keys(t))
1175
1171
  if (key !== 'command' && key !== 'timeout' && key !== 'env')
@@ -1179,13 +1175,13 @@ function translation(
1179
1175
  const timeout = t.timeout
1180
1176
  if (
1181
1177
  timeout !== undefined &&
1182
- !(typeof timeout === 'number' && Number.isInteger(timeout) && timeout > 0)
1178
+ !(typeof timeout === 'string' && /^[1-9]\d*(ms|s|m|h|d)$/.test(timeout))
1183
1179
  )
1184
- throw new UserError(`${at}: timeout must be a positive integer (ms)`)
1180
+ throw new UserError(`${at}: timeout must be a duration with its unit, like '30s' or '10m'`)
1185
1181
  const env = t.env ?? {}
1186
1182
  if (!isRecord(env) || Object.values(env).some((v) => typeof v !== 'string'))
1187
1183
  throw new UserError(`${at}: env must map names to strings`)
1188
- return { command: t.command, env, timeout }
1184
+ return { command: t.command, env, timeout: timeout as Duration | undefined }
1189
1185
  }
1190
1186
 
1191
1187
  /**
@@ -1354,7 +1350,7 @@ function passConfiguration(
1354
1350
  nodeName: string,
1355
1351
  configuration: string,
1356
1352
  nxEdges: (from: string, target: string) => readonly string[],
1357
- metaByNode: ReadonlyMap<string, ProjectMeta>,
1353
+ metaByNode: ReadonlyMap<string, PackageMeta>,
1358
1354
  emittedIds: ReadonlySet<string>,
1359
1355
  taskNameFor: TaskNameFor,
1360
1356
  ): void {
@@ -1394,7 +1390,7 @@ function narrowToNxGraph(
1394
1390
  pkgName: string,
1395
1391
  nxClosure: (node: string) => ReadonlySet<string>,
1396
1392
  nxEdges: (from: string, target: string) => readonly string[],
1397
- metaByNode: ReadonlyMap<string, ProjectMeta>,
1393
+ metaByNode: ReadonlyMap<string, PackageMeta>,
1398
1394
  emittedIds: ReadonlySet<string>,
1399
1395
  directDeps: (pkg: string) => readonly string[],
1400
1396
  ): void {
@@ -1436,7 +1432,7 @@ function followNxGraph(
1436
1432
  nodeName: string,
1437
1433
  pkgName: string,
1438
1434
  nxEdges: (from: string, target: string) => readonly string[],
1439
- metaByNode: ReadonlyMap<string, ProjectMeta>,
1435
+ metaByNode: ReadonlyMap<string, PackageMeta>,
1440
1436
  emittedIds: ReadonlySet<string>,
1441
1437
  reached: (name: string) => ReadonlySet<string>,
1442
1438
  ): void {
@@ -16,7 +16,8 @@
16
16
  // goes on through it. Nx allows a project cycle and vx's task graph does
17
17
  // not: inside a cycle a twin carries its peers' files as workspace globs
18
18
  // and edges only out of the cycle.
19
- import type { GeneratedTask, ProjectMeta } from '@vzn/vx'
19
+ import type { GeneratedTask } from '@vzn/vx'
20
+ import type { PackageMeta } from '../packages.js'
20
21
  import { emptyNxInputs, expandNxInputs, type NxInputs } from './nx-inputs.js'
21
22
  import { nxProjectOutputs } from './nx-outputs.js'
22
23
  import { matchNxProjects } from './nx-deps.js'
@@ -70,7 +71,7 @@ export function planNxUpstream(
70
71
  nodes: Readonly<Record<string, GraphNode>>,
71
72
  dependencies: unknown,
72
73
  workspaceNamed: Readonly<Record<string, unknown[]>> | null,
73
- metaByNode: ReadonlyMap<string, ProjectMeta>,
74
+ metaByNode: ReadonlyMap<string, PackageMeta>,
74
75
  ): NxUpstream {
75
76
  const isProject = (n: string): boolean => typeof nodes[n]?.data?.root === 'string'
76
77
  const projectTags = Object.entries(nodes).map(([name, n]) => ({