@vzn/vx-migrate 0.0.0 → 0.0.485
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,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
|
+
}
|