@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,231 @@
1
+ // An Nx target's `outputs` as vx's cache outputs: project globs and
2
+ // workspace-root globs. Extracted from `buildTask` in item 606.
3
+
4
+ import path from 'node:path'
5
+ import { minimatchToVx } from '../glob-grammar.js'
6
+ import { takingBack } from '../shared-outputs.js'
7
+
8
+ interface NxOutputs {
9
+ readonly outFiles: string[]
10
+ readonly wsOutFiles: string[]
11
+ }
12
+
13
+ /**
14
+ * Nx's `interpolate` for a path, as a workspace-relative one: `{workspaceRoot}`
15
+ * is the root and `{projectRoot}` / `{projectName}` the project's, ANYWHERE
16
+ * in the string — `{workspaceRoot}/coverage/{projectRoot}` is `@nx/jest`'s
17
+ * output. Only a leading token was read before item 912, so that output
18
+ * became the literal glob `coverage/{projectRoot}/**`, saved nothing, and a
19
+ * hit restored nothing. Null when a token is left or the path leaves the
20
+ * workspace; a brace set is no token (item 914 — 912 read one as a gap).
21
+ */
22
+ export function nxWorkspacePath(s: string, projectRel: string, projectName: string): string | null {
23
+ const rel = projectRel === '.' ? '' : projectRel
24
+ const p = s
25
+ .replaceAll('{workspaceRoot}', '')
26
+ .replaceAll('{projectRoot}', rel)
27
+ .replaceAll('{projectName}', projectName)
28
+ // A token left over is a gap; a brace set (`*.{ts,tsx}`) is a glob.
29
+ if (/\{[\w.]+\}/.test(p)) return null
30
+ const n = path.posix.normalize(p.replace(/^\/+/, '')).replace(/^\.\//, '')
31
+ return n === '.' || n === '..' || n.startsWith('../') ? null : n
32
+ }
33
+
34
+ /** A workspace path relative to the project dir, or null when it lies outside. */
35
+ export function underProject(p: string, projectRel: string): string | null {
36
+ if (projectRel === '.' || projectRel === '') return p
37
+ return p.startsWith(`${projectRel}/`) ? p.slice(projectRel.length + 1) : null
38
+ }
39
+
40
+ /**
41
+ * What Nx caches for a target that declares no `outputs`
42
+ * (`getOutputsForTargetAndConfiguration`): its `options.outputPath`, else,
43
+ * for `build` and `prepare`, `dist/{root}`, `{root}/dist`, `{root}/build`
44
+ * and `{root}/public`. Read as no outputs, a cached target's hit restored
45
+ * nothing (item 1052). vx cleans an output before the task runs, and a
46
+ * project's `build/` or `public/` is as often its committed sources, which
47
+ * Nx, never cleaning, leaves alone: with `tops` (the project's tracked
48
+ * top-level names) one git tracks nothing under is an output, and one it
49
+ * does is left out with a todo; without `tops`, both are.
50
+ */
51
+ export function nxDefaultOutputs(
52
+ targetName: string,
53
+ options: Record<string, unknown>,
54
+ projectRel: string,
55
+ todos: string[],
56
+ tops?: ReadonlySet<string>,
57
+ ): string[] {
58
+ const outputPath = options['outputPath']
59
+ if (typeof outputPath === 'string') return [outputPath]
60
+ // A list is each of its paths, as Nx takes it; read as no `outputPath`,
61
+ // the build cached the default directories and a hit restored none.
62
+ if (Array.isArray(outputPath) && outputPath.length > 0) {
63
+ if (outputPath.every((o) => typeof o === 'string')) return outputPath
64
+ }
65
+ if (targetName !== 'build' && targetName !== 'prepare') return []
66
+ const root = projectRel === '.' ? '' : projectRel
67
+ const at = (p: string): string => path.posix.join(root, p)
68
+ const held = ['build', 'public'].filter((d) => tops === undefined || tops.has(d))
69
+ if (held.length > 0)
70
+ todos.push(
71
+ `no outputs declared: Nx also caches ${held.map(at).join(' and ')} for this target — vx cleans an output before the run, so add ${held.length === 1 ? 'it' : 'them'} to the outputs by hand only if ${held.length === 1 ? 'it holds' : 'they hold'} nothing committed`,
72
+ )
73
+ // The root project's two are one path.
74
+ return [
75
+ ...new Set([path.posix.join('dist', root), at('dist')]),
76
+ ...['build', 'public'].filter((d) => !held.includes(d)).map(at),
77
+ ]
78
+ }
79
+
80
+ /** `outputs` with `{options.x}` resolved against `options`; `projectRel` is the project dir, `.` for the root. */
81
+ export function mapNxOutputs(
82
+ outputs: readonly string[],
83
+ options: Record<string, unknown>,
84
+ projectRel: string,
85
+ projectName: string,
86
+ todos: string[],
87
+ ): NxOutputs {
88
+ const outFiles: string[] = []
89
+ const wsOutFiles: string[] = []
90
+ outputs: for (const o of outputs) {
91
+ // A `!` output takes a path back, as Nx's does (A-44); it maps as its
92
+ // path does and keeps the `!`.
93
+ const neg = o.startsWith('!') ? '!' : ''
94
+ let s = o.slice(neg.length)
95
+ // Every `{options.x}`, not the first: `dist/{options.a}/{options.b}`.
96
+ for (const optTok of o.matchAll(/\{options\.([^}]+)\}/g)) {
97
+ // A dotted path walks the options, as Nx's `_interpolate` does:
98
+ // @nx/angular:application's `{options.outputPath.base}` read as one
99
+ // key was no output, and a cached build's hit restored nothing.
100
+ let v: unknown = options
101
+ for (const k of optTok[1]!.split('.')) {
102
+ v = v !== null && typeof v === 'object' ? (v as Record<string, unknown>)[k] : undefined
103
+ if (!v) break
104
+ }
105
+ // Nx leaves a falsy option's token in place and drops the output
106
+ // (`getOutputsForTargetAndConfiguration`): @nx/eslint's
107
+ // `{options.outputFile}` with no outputFile is no output, not a todo
108
+ // on every lint target (2026-09-28).
109
+ if (!v) continue outputs
110
+ // Nx's template replacement writes a number or a boolean as its text.
111
+ if (typeof v === 'number' || typeof v === 'boolean') {
112
+ s = s.replace(optTok[0], String(v))
113
+ continue
114
+ }
115
+ if (typeof v !== 'string') {
116
+ todos.push(
117
+ `output ${JSON.stringify(o)}: option ${JSON.stringify(optTok[1])} is not a literal ` +
118
+ 'string — resolve manually',
119
+ )
120
+ continue outputs
121
+ }
122
+ s = s.replace(optTok[0], v)
123
+ }
124
+ if (s.includes('{')) {
125
+ const p = nxWorkspacePath(s, projectRel, projectName)
126
+ if (p === null) {
127
+ todos.push(`output ${JSON.stringify(o)} uses a token vx does not support`)
128
+ continue
129
+ }
130
+ s = p
131
+ }
132
+ // Plain paths resolve against the workspace root in nx. One outside
133
+ // the project dir is Nx's DEFAULT layout (`@nx/js:tsc` writes
134
+ // `dist/<project>` at the root), so it is the workspace-root output it
135
+ // is, not a gap: as a todo, every such target hit green and restored
136
+ // NOTHING (item 593, the bench workspace's 1,000 `build` targets).
137
+ // As written: core reads a literal as the path or the tree under it
138
+ // (`asTrees`), and keeps a hit's directory short-circuit for either. The
139
+ // `<rel>/**` this wrote for an extensionless name matched nothing under
140
+ // a FILE, so a binary like `dist/bin/tool` was never saved (Next 27).
141
+ // A path that leaves the workspace (an old generator's
142
+ // `reportsDirectory: "../../coverage/<lib>"`, read from the workspace
143
+ // root as Nx 23 reads it) has no place in a config: core refuses `..`,
144
+ // so the written file failed to load and every task with it.
145
+ const norm = path.posix.normalize(s.replace(/^\/+/, ''))
146
+ if (norm === '..' || norm.startsWith('../')) {
147
+ todos.push(
148
+ `output ${JSON.stringify(o)} resolves to ${JSON.stringify(norm)}, outside the workspace — ` +
149
+ 'vx caches only inside it; dropped',
150
+ )
151
+ continue
152
+ }
153
+ const globs = vxOutputGlobs(neg + s)
154
+ if (globs === null) {
155
+ todos.push(`output ${JSON.stringify(o)} has a glob form vx cannot spell — dropped`)
156
+ continue
157
+ }
158
+ for (const g of globs) {
159
+ const gn = g.startsWith('!') ? '!' : ''
160
+ const gs = g.slice(gn.length)
161
+ if (projectRel === '.') outFiles.push(g)
162
+ else if (gs.startsWith(`${projectRel}/`)) outFiles.push(gn + gs.slice(projectRel.length + 1))
163
+ else wsOutFiles.push(gn + path.posix.normalize(gs).replace(/^\.\//, ''))
164
+ }
165
+ }
166
+ return { outFiles: takingBack(outFiles), wsOutFiles: takingBack(wsOutFiles) }
167
+ }
168
+
169
+ /**
170
+ * An Nx output in vx's glob grammar, or null when it has none. Next's
171
+ * inferred build writes `.next/!(cache)/**\/*`, which vx read as a literal
172
+ * `!(cache)` dir: the build saved nothing and a hit restored no `.next`.
173
+ * A whole-segment `!(a|b)` is `*` with `!` outputs taking `{a,b}` back;
174
+ * the rest is `minimatchToVx`'s.
175
+ */
176
+ function vxOutputGlobs(o: string): string[] | null {
177
+ const negated = o.startsWith('!')
178
+ const segs = o.slice(negated ? 1 : 0).split('/')
179
+ const at = segs.findIndex((seg) => /^!\([^()|{},]+(\|[^()|{},]+)*\)$/.test(seg))
180
+ if (at !== -1) {
181
+ if (negated) return null
182
+ const alts = segs[at]!.slice(2, -1).split('|')
183
+ const back = alts.length === 1 ? alts[0]! : `{${alts.join(',')}}`
184
+ const pos = vxOutputGlobs([...segs.slice(0, at), '*', ...segs.slice(at + 1)].join('/'))
185
+ const neg = minimatchToVx([...segs.slice(0, at), back, ...segs.slice(at + 1)].join('/'), true)
186
+ return pos === null || neg === null ? null : [...pos, `!${neg}`]
187
+ }
188
+ const g = minimatchToVx(segs.join('/'), negated)
189
+ return g === null ? null : [(negated ? '!' : '') + g]
190
+ }
191
+
192
+ /**
193
+ * The literal paths a project's targets write, workspace-relative. An input
194
+ * inside one is generated: Nx hashes it from disk (TanStack/table's
195
+ * `public` input lists `{projectRoot}/dist` for every `^public`), and vx
196
+ * refuses a path git does not list.
197
+ */
198
+ export function nxProjectOutputs(
199
+ targets:
200
+ | Readonly<Record<string, { outputs?: string[]; options?: Record<string, unknown> }>>
201
+ | undefined,
202
+ projectRel: string,
203
+ projectName: string,
204
+ ): string[] {
205
+ if (targets === undefined) return []
206
+ const known = projectOutputsMemo.get(targets)
207
+ if (known !== undefined) return known
208
+ const out = new Set<string>()
209
+ const scratch: string[] = []
210
+ const rel = projectRel === '.' ? '' : projectRel
211
+ for (const [name, t] of Object.entries(targets)) {
212
+ const options = t.options ?? {}
213
+ const { outFiles, wsOutFiles } = mapNxOutputs(
214
+ t.outputs ?? nxDefaultOutputs(name, options, projectRel, scratch),
215
+ options,
216
+ projectRel,
217
+ projectName,
218
+ scratch,
219
+ )
220
+ for (const f of [...outFiles.map((f) => path.posix.join(rel, f)), ...wsOutFiles]) {
221
+ const lit = f.replace(/\/\*\*(\/\*)?$/, '')
222
+ if (!lit.startsWith('!') && !/[*?[{]/.test(lit)) out.add(lit)
223
+ }
224
+ }
225
+ const r = [...out]
226
+ projectOutputsMemo.set(targets, r)
227
+ return r
228
+ }
229
+
230
+ // Keyed on the node's own targets object: every target of a project asks.
231
+ const projectOutputsMemo = new WeakMap<object, string[]>()
@@ -0,0 +1,356 @@
1
+ // Nx's `^name` inputs over the project graph (item 910). Nx hashes a
2
+ // dependency's `name` input for every project the graph reaches, whether or
3
+ // not a task edge exists, so a `test` with `^production` and no `dependsOn`
4
+ // re-keys on a dependency's source. vx folds upstream KEYS along
5
+ // `dependsOn`, so each project gets one `nx-input:<name>` task — `true`,
6
+ // cached, keyed on the project's own `name` input — with an edge to its
7
+ // dependencies' twin. A task that reads `^name` depends on its direct
8
+ // dependencies' twins and folds the whole closure through them: each
9
+ // project's files are hashed once, not once per dependant (listing the
10
+ // closure's globs on every task was 2.5 million globs at 1,000 projects).
11
+ //
12
+ // The edges are the NX graph's, not vx's package graph (`^` in vx follows
13
+ // package.json, and an Nx edge from a tsconfig path has no manifest entry),
14
+ // so they are explicit `pkg#nx-input:<name>`. A graph node with no vx
15
+ // project is transparent: its files join as workspace globs and the walk
16
+ // goes on through it. Nx allows a project cycle and vx's task graph does
17
+ // not: inside a cycle a twin carries its peers' files as workspace globs
18
+ // and edges only out of the cycle.
19
+ import type { GeneratedTask, ProjectMeta } from '@vzn/vx'
20
+ import { emptyNxInputs, expandNxInputs, type NxInputs } from './nx-inputs.js'
21
+ import { nxProjectOutputs } from './nx-outputs.js'
22
+ import { matchNxProjects } from './nx-deps.js'
23
+
24
+ interface GraphNode {
25
+ name?: string
26
+ data?: {
27
+ root?: string
28
+ tags?: unknown[]
29
+ namedInputs?: Record<string, unknown[]>
30
+ targets?: Record<string, { outputs?: string[]; options?: Record<string, unknown> }>
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Nx reads `^{projectRoot}/…` and `^{workspaceRoot}/…` as each
36
+ * dependency's FILESET, not a named input
37
+ * (`splitInputsIntoSelfAndDependencies`), and so is
38
+ * `{ fileset, dependencies: true }`. Looked up as a named input, it was a
39
+ * todo and nothing: a dependency's `tsconfig.lib.json` edit re-keyed no
40
+ * dependant.
41
+ */
42
+ const isFileset = (name: string): boolean =>
43
+ name.startsWith('{projectRoot}') || name.startsWith('{workspaceRoot}')
44
+
45
+ /** A fileset twin is named by its hash: a glob's `*` is no task name. */
46
+ const inputTask = (name: string): string =>
47
+ isFileset(name)
48
+ ? `nx-input:fileset-${Bun.hash.xxHash3(name).toString(16).padStart(16, '0')}`
49
+ : `nx-input:${name}`
50
+
51
+ function normRel(p: string): string {
52
+ const s = p.replace(/\/+$/, '')
53
+ return s === '.' ? '' : s
54
+ }
55
+
56
+ export interface NxUpstream {
57
+ /** Nx's merge: an implicit `default` of the whole project, nx.json's, the project's. */
58
+ namedOf(node: string): Readonly<Record<string, unknown[]>>
59
+ /**
60
+ * Folds `into.upstream` for a task of `node`: the `dependsOn` edges it
61
+ * needs, with a transparent node's files added to `into`.
62
+ */
63
+ resolve(node: string, into: NxInputs, todos: string[]): string[]
64
+ /** Each project's `nx-input:<name>` tasks, for every twin an edge reaches. */
65
+ inputTasks(): Map<string, GeneratedTask[]>
66
+ }
67
+
68
+ export function planNxUpstream(
69
+ nodes: Readonly<Record<string, GraphNode>>,
70
+ dependencies: unknown,
71
+ workspaceNamed: Readonly<Record<string, unknown[]>> | null,
72
+ metaByNode: ReadonlyMap<string, ProjectMeta>,
73
+ ): NxUpstream {
74
+ const isProject = (n: string): boolean => typeof nodes[n]?.data?.root === 'string'
75
+ const projectTags = Object.entries(nodes).map(([name, n]) => ({
76
+ name,
77
+ tags: Array.isArray(n?.data?.tags) ? n.data.tags.filter((t) => typeof t === 'string') : [],
78
+ ...(typeof n?.data?.root === 'string' ? { root: n.data.root } : {}),
79
+ }))
80
+ const direct = new Map<string, string[]>()
81
+ if (typeof dependencies === 'object' && dependencies !== null) {
82
+ for (const [source, edges] of Object.entries(
83
+ dependencies as Record<string, Array<{ target?: unknown }>>,
84
+ )) {
85
+ if (!Array.isArray(edges)) continue
86
+ // An `npm:` node has no root: it is a package, not a project.
87
+ const to = edges
88
+ .map((e) => e?.target)
89
+ .filter((t): t is string => typeof t === 'string' && t !== source && isProject(t))
90
+ direct.set(source, [...new Set(to)])
91
+ }
92
+ }
93
+
94
+ // The vx projects a node reaches first, through any transparent nodes,
95
+ // and the transparent nodes on the way.
96
+ const reachMemo = new Map<string, { edges: string[]; through: string[] }>()
97
+ const reach = (node: string): { edges: string[]; through: string[] } => {
98
+ const known = reachMemo.get(node)
99
+ if (known !== undefined) return known
100
+ const edges = new Set<string>()
101
+ const through = new Set<string>()
102
+ const stack = [...(direct.get(node) ?? [])]
103
+ while (stack.length > 0) {
104
+ const n = stack.pop()!
105
+ if (n === node || edges.has(n) || through.has(n)) continue
106
+ if (metaByNode.has(n)) edges.add(n)
107
+ else {
108
+ through.add(n)
109
+ stack.push(...(direct.get(n) ?? []))
110
+ }
111
+ }
112
+ const r = { edges: [...edges].sort(), through: [...through].sort() }
113
+ reachMemo.set(node, r)
114
+ return r
115
+ }
116
+
117
+ // Tarjan's strongly connected components over the vx projects.
118
+ const component = new Map<string, string[]>()
119
+ {
120
+ let index = 0
121
+ const idx = new Map<string, number>()
122
+ const low = new Map<string, number>()
123
+ const onStack = new Set<string>()
124
+ const stack: string[] = []
125
+ const visit = (root: string): void => {
126
+ // Iterative: a deep graph must not overflow the call stack.
127
+ const work: Array<{ node: string; next: number }> = [{ node: root, next: 0 }]
128
+ idx.set(root, index)
129
+ low.set(root, index++)
130
+ stack.push(root)
131
+ onStack.add(root)
132
+ while (work.length > 0) {
133
+ const frame = work[work.length - 1]!
134
+ const out = reach(frame.node).edges
135
+ if (frame.next < out.length) {
136
+ const w = out[frame.next++]!
137
+ if (!idx.has(w)) {
138
+ idx.set(w, index)
139
+ low.set(w, index++)
140
+ stack.push(w)
141
+ onStack.add(w)
142
+ work.push({ node: w, next: 0 })
143
+ } else if (onStack.has(w)) {
144
+ low.set(frame.node, Math.min(low.get(frame.node)!, idx.get(w)!))
145
+ }
146
+ continue
147
+ }
148
+ work.pop()
149
+ const parent = work[work.length - 1]
150
+ if (parent !== undefined)
151
+ low.set(parent.node, Math.min(low.get(parent.node)!, low.get(frame.node)!))
152
+ if (low.get(frame.node) === idx.get(frame.node)) {
153
+ const members: string[] = []
154
+ let w: string
155
+ do {
156
+ w = stack.pop()!
157
+ onStack.delete(w)
158
+ members.push(w)
159
+ } while (w !== frame.node)
160
+ members.sort()
161
+ for (const m of members) component.set(m, members)
162
+ }
163
+ }
164
+ }
165
+ for (const n of [...metaByNode.keys()].sort()) if (!idx.has(n)) visit(n)
166
+ }
167
+
168
+ const namedMemo = new Map<string, Readonly<Record<string, unknown[]>>>()
169
+ const namedOf = (node: string): Readonly<Record<string, unknown[]>> => {
170
+ let named = namedMemo.get(node)
171
+ if (named === undefined) {
172
+ named = {
173
+ default: ['{projectRoot}/**/*'],
174
+ ...workspaceNamed,
175
+ ...nodes[node]?.data?.namedInputs,
176
+ }
177
+ namedMemo.set(node, named)
178
+ }
179
+ return named
180
+ }
181
+
182
+ // A node's `name` input, its project globs rewritten under its root.
183
+ const underMemo = new Map<string, { inputs: NxInputs; todos: string[] }>()
184
+ const under = (node: string, name: string): { inputs: NxInputs; todos: string[] } => {
185
+ const key = `${node}\0${name}`
186
+ const known = underMemo.get(key)
187
+ if (known !== undefined) return known
188
+ const own = emptyNxInputs()
189
+ const todos: string[] = []
190
+ expandOwn(node, name, own, todos)
191
+ const rel = normRel(nodes[node]?.data?.root ?? '')
192
+ const inputs = emptyNxInputs()
193
+ for (const g of own.files) {
194
+ const neg = g.startsWith('!') ? '!' : ''
195
+ inputs.wsFiles.push(rel === '' ? g : `${neg}${rel}/${neg === '' ? g : g.slice(1)}`)
196
+ }
197
+ inputs.wsFiles.push(...own.wsFiles)
198
+ inputs.envNames.push(...own.envNames)
199
+ inputs.runtimeCmds.push(...own.runtimeCmds)
200
+ inputs.upstream.push(...own.upstream)
201
+ const r = { inputs, todos }
202
+ underMemo.set(key, r)
203
+ return r
204
+ }
205
+ const expandOwn = (node: string, name: string, into: NxInputs, todos: string[]): void => {
206
+ if (!isFileset(name) && namedOf(node)[name] === undefined) {
207
+ // Its whole project, as `expandNxInputs` keys an unknown own input.
208
+ into.files.push('**/*')
209
+ todos.push(
210
+ `named input ${JSON.stringify(name)} not found for ${JSON.stringify(node)} — keyed on its whole project (\`**/*\`) until its globs are declared`,
211
+ )
212
+ return
213
+ }
214
+ const rel = normRel(nodes[node]?.data?.root ?? '')
215
+ const nm = nodes[node]?.name ?? node
216
+ const at = {
217
+ rel,
218
+ name: nm,
219
+ outputs: nxProjectOutputs(nodes[node]?.data?.targets, rel || '.', nm),
220
+ }
221
+ expandNxInputs([name], namedOf(node), at, into, todos)
222
+ }
223
+
224
+ const merge = (from: NxInputs, into: NxInputs): void => {
225
+ into.wsFiles.push(...from.wsFiles)
226
+ for (const n of from.envNames) if (!into.envNames.includes(n)) into.envNames.push(n)
227
+ for (const c of from.runtimeCmds) if (!into.runtimeCmds.includes(c)) into.runtimeCmds.push(c)
228
+ }
229
+
230
+ // The twins some edge reaches, and only those: one per project per name
231
+ // was a twin no task depends on for every project nothing depends on,
232
+ // loaded and keyed on every run (Next 25).
233
+ const wanted = new Set<string>()
234
+ const queue: Array<readonly [node: string, name: string]> = []
235
+ const want = (node: string, name: string): void => {
236
+ const key = `${node}\0${name}`
237
+ if (wanted.has(key)) return
238
+ wanted.add(key)
239
+ queue.push([node, name])
240
+ }
241
+
242
+ // `name` of everything `self` reaches, as edges to twins, except
243
+ // `peers` — the other members of `self`'s cycle, which a twin carries as
244
+ // files. `visited` ends the walk of a nested `^` through a cycle of
245
+ // transparent nodes.
246
+ const fold = (
247
+ self: string,
248
+ name: string,
249
+ peers: ReadonlySet<string>,
250
+ into: NxInputs,
251
+ todos: string[],
252
+ edges: Set<string>,
253
+ visited: Set<string> = new Set(),
254
+ ): void => {
255
+ const files = new Set<string>()
256
+ for (const s of peers.size > 0 ? peers : [self]) {
257
+ const r = reach(s)
258
+ for (const t of r.through) files.add(t)
259
+ for (const e of r.edges) {
260
+ if (peers.has(e)) continue
261
+ edges.add(`${metaByNode.get(e)!.name}#${inputTask(name)}`)
262
+ want(e, name)
263
+ }
264
+ }
265
+ for (const p of peers) if (p !== self) files.add(p)
266
+ for (const f of [...files].sort()) {
267
+ if (visited.has(`${f}\0${name}`)) continue
268
+ visited.add(`${f}\0${name}`)
269
+ const r = under(f, name)
270
+ merge(r.inputs, into)
271
+ todos.push(...r.todos)
272
+ // A folded node's own nested `^other` goes on from it; `^name` itself
273
+ // is the closure this walk already covers.
274
+ for (const u of r.inputs.upstream)
275
+ if (u.of === 'deps' && u.name !== name)
276
+ fold(f, u.name, new Set(), into, todos, edges, visited)
277
+ }
278
+ }
279
+
280
+ // `{ input, projects }`: those projects' twins (Nx reads each one's own
281
+ // input; the twin's closure is a superset, which only costs hits).
282
+ const foldProjects = (
283
+ name: string,
284
+ of: readonly string[],
285
+ into: NxInputs,
286
+ todos: string[],
287
+ edges: Set<string>,
288
+ ): void => {
289
+ // Nx reads the list as `dependsOn`'s `projects` (`findMatchingProjects`):
290
+ // `*` patterns, `tag:` and `!` exclusions. Looked up as names, a
291
+ // `tag:shared` reader was a todo and its input dropped from the key.
292
+ const literal = of.filter(
293
+ (p) =>
294
+ !/^!|^tag:|\*/.test(p) &&
295
+ nodes[p] === undefined &&
296
+ matchNxProjects([p], projectTags).length === 0,
297
+ )
298
+ for (const p of literal)
299
+ todos.push(`input project ${JSON.stringify(p)} is not a graph node — map manually`)
300
+ for (const p of matchNxProjects(of, projectTags)) {
301
+ const u = { name }
302
+ const meta = metaByNode.get(p)
303
+ if (meta !== undefined) {
304
+ edges.add(`${meta.name}#${inputTask(u.name)}`)
305
+ want(p, u.name)
306
+ } else if (isProject(p)) {
307
+ const r = under(p, u.name)
308
+ merge(r.inputs, into)
309
+ todos.push(...r.todos)
310
+ }
311
+ }
312
+ }
313
+
314
+ const resolve = (node: string, into: NxInputs, todos: string[]): string[] => {
315
+ const edges = new Set<string>()
316
+ for (const u of into.upstream) {
317
+ if (u.of === 'deps') fold(node, u.name, new Set(), into, todos, edges)
318
+ else foldProjects(u.name, u.of, into, todos, edges)
319
+ }
320
+ return [...edges]
321
+ }
322
+
323
+ const inputTasks = (): Map<string, GeneratedTask[]> => {
324
+ const out = new Map<string, GeneratedTask[]>()
325
+ for (let i = 0; i < queue.length; i++) {
326
+ const [node, name] = queue[i]!
327
+ const todos: string[] = []
328
+ const inputs = emptyNxInputs()
329
+ expandOwn(node, name, inputs, todos)
330
+ const members = component.get(node) ?? [node]
331
+ const peers = new Set(members.length > 1 ? members : [])
332
+ const edges = new Set<string>()
333
+ fold(node, name, peers, inputs, todos, edges)
334
+ // A nested `^other` in this project's `name` input.
335
+ for (const u of inputs.upstream) {
336
+ if (u.of !== 'deps') foldProjects(u.name, u.of, inputs, todos, edges)
337
+ else if (u.name !== name) fold(node, u.name, peers, inputs, todos, edges)
338
+ }
339
+ const cacheInputs: Record<string, unknown> = { files: inputs.files }
340
+ if (inputs.wsFiles.length > 0) cacheInputs.workspaceFiles = inputs.wsFiles
341
+ if (inputs.envNames.length > 0) cacheInputs.env = inputs.envNames
342
+ if (inputs.runtimeCmds.length > 0) cacheInputs.workspaceRuntime = inputs.runtimeCmds
343
+ const task: Record<string, unknown> = { exec: { command: 'true' } }
344
+ if (isFileset(name))
345
+ task.description = `Nx fileset ${name} of this project and what it reaches`
346
+ if (edges.size > 0) task.dependsOn = [...edges].sort()
347
+ task.cache = { inputs: cacheInputs, outputs: { files: [] } }
348
+ const list = out.get(node) ?? []
349
+ list.push({ name: inputTask(name), task, todos })
350
+ out.set(node, list)
351
+ }
352
+ return out
353
+ }
354
+
355
+ return { namedOf, resolve, inputTasks }
356
+ }