@vzn/vx-migrate 0.0.652 → 0.0.653

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/migrate.ts CHANGED
@@ -12,7 +12,18 @@
12
12
  // The mappers return a plan; core's migration seam
13
13
  // (`applyMigration`) renders, guards, writes and reports.
14
14
 
15
- import { existsSync, readFileSync, realpathSync } from 'node:fs'
15
+ import {
16
+ closeSync,
17
+ constants,
18
+ existsSync,
19
+ lstatSync,
20
+ mkdirSync,
21
+ openSync,
22
+ readFileSync,
23
+ readlinkSync,
24
+ realpathSync,
25
+ writeFileSync,
26
+ } from 'node:fs'
16
27
  import { mkdtemp, rm } from 'node:fs/promises'
17
28
  import os from 'node:os'
18
29
  import path from 'node:path'
@@ -36,6 +47,7 @@ import {
36
47
  type PackageMeta,
37
48
  type PackageWorkspace,
38
49
  reachesManifest,
50
+ undecodableMembers,
39
51
  unreachedHint,
40
52
  unreachedPackages,
41
53
  } from './packages.js'
@@ -55,6 +67,7 @@ import {
55
67
  MODE_QUESTION,
56
68
  ownVersion,
57
69
  packageManagerOf,
70
+ type PackageManager,
58
71
  parseModeAnswer,
59
72
  uninstall,
60
73
  } from './adopt.js'
@@ -327,9 +340,11 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
327
340
  writesWorkspace && !bare
328
341
  ? [
329
342
  ...kept,
330
- ...workspacePlugins(root, runner === 'vite-task' ? undefined : runner).filter(
331
- (p) => !kept.some((k) => k.factory === p.factory),
332
- ),
343
+ ...workspacePlugins(
344
+ root,
345
+ runner === 'vite-task' ? undefined : runner,
346
+ installer(root, parsed),
347
+ ).filter((p) => !kept.some((k) => k.factory === p.factory)),
333
348
  ]
334
349
  : []
335
350
  let workspaceText: string | undefined
@@ -490,10 +505,60 @@ async function rewriteWorkspace(
490
505
  const names = dropped.map((p) => `${p.factory}()`).join(', ')
491
506
  if (dry)
492
507
  return [['config', `would ${dropped.length > 0 ? `drop ${names} from` : 'rewrite'} ${file}`]]
493
- await Bun.write(path.join(root, file), text)
508
+ writeNoFollow(root, path.join(root, file), text)
494
509
  return [['config', `${dropped.length > 0 ? `dropped ${names} from` : 'rewrote'} ${file}`]]
495
510
  }
496
511
 
512
+ /**
513
+ * Refuses a `file` that is a symlink, a dangling one too: a repo can carry
514
+ * `.vx/.gitignore -> /anywhere`, and a write through it lands outside.
515
+ */
516
+ function refuseSymlink(root: string, file: string): void {
517
+ let link: string
518
+ try {
519
+ if (!lstatSync(file).isSymbolicLink()) return
520
+ link = readlinkSync(file)
521
+ } catch {
522
+ // quiet: nothing there to write through
523
+ return
524
+ }
525
+ throw new UserError(
526
+ `refusing to write ${relPosix(root, file)}: it is a symlink to ${link}; remove the link and run again`,
527
+ )
528
+ }
529
+
530
+ /**
531
+ * Writes `text` to `file`, never through a link: O_NOFOLLOW refuses one
532
+ * put there after `refuseSymlink`'s check.
533
+ */
534
+ function writeNoFollow(root: string, file: string, text: string): void {
535
+ refuseSymlink(root, file)
536
+ mkdirSync(path.dirname(file), { recursive: true })
537
+ let fd: number
538
+ try {
539
+ fd = openSync(
540
+ file,
541
+ constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW,
542
+ 0o666,
543
+ )
544
+ } catch (err) {
545
+ if ((err as NodeJS.ErrnoException).code === 'ELOOP') refuseSymlink(root, file)
546
+ throw err
547
+ }
548
+ try {
549
+ writeFileSync(fd, text)
550
+ } finally {
551
+ closeSync(fd)
552
+ }
553
+ }
554
+
555
+ /** The manager `prepareRepo` installs with, or none under `--no-install`. */
556
+ function installer(root: string, args: MigrateArgs): PackageManager | undefined {
557
+ return args.noInstall === true
558
+ ? undefined
559
+ : packageManagerOf(root, process.env['npm_config_user_agent'])
560
+ }
561
+
497
562
  /**
498
563
  * Install what the written files import and remove what they no longer
499
564
  * do. Returns the report's rows; changes nothing under `--dry`.
@@ -555,7 +620,10 @@ async function keep(
555
620
  ): Promise<number> {
556
621
  const format: MigrationFormat = args.mjs ? 'mjs' : 'ts'
557
622
  const name = `.vx/workspace.${format}`
558
- const all = [{ pkg: '@vzn/vx-migrate', factory: runner }, ...workspacePlugins(root, runner)]
623
+ const all = [
624
+ { pkg: '@vzn/vx-migrate', factory: runner },
625
+ ...workspacePlugins(root, runner, installer(root, args)),
626
+ ]
559
627
  const existing = workspaceFileAt(root)
560
628
  const before =
561
629
  existing === undefined || args.force
@@ -571,6 +639,14 @@ async function keep(
571
639
  const extended = before === undefined ? null : extendWorkspaceFile(before, all)
572
640
  const text = before === undefined ? renderWorkspaceFile(all, format) : (extended ?? before)
573
641
  const declares = before === undefined || extended !== null
642
+ const ignore = existsSync(path.join(root, '.vx', '.gitignore')) ? [] : ['.vx/.gitignore']
643
+ const writes = [
644
+ ...(before === undefined ? [name] : text !== before ? [existing!] : []),
645
+ ...ignore,
646
+ ].map((f) => path.join(root, f))
647
+ // Before the install and the first write: a link (a dangling one reads
648
+ // as absent above) is refused with nothing changed.
649
+ if (!args.dry) for (const f of writes) refuseSymlink(root, f)
574
650
  const steps = await prepareRepo(
575
651
  root,
576
652
  [
@@ -585,14 +661,12 @@ async function keep(
585
661
  )
586
662
  if (args.json)
587
663
  for (const [label, value] of steps) ui.say('info', `vx-migrate: ${label}: ${value}`)
588
- const ignore = existsSync(path.join(root, '.vx', '.gitignore')) ? [] : ['.vx/.gitignore']
589
664
  if (!args.dry) {
590
- if (before === undefined) {
591
- if (existing !== undefined && existing !== name) await rm(path.join(root, existing))
592
- await Bun.write(path.join(root, name), text)
593
- } else if (text !== before) await Bun.write(path.join(root, existing!), text)
665
+ if (before === undefined && existing !== undefined && existing !== name)
666
+ await rm(path.join(root, existing))
594
667
  // vx's own housekeeping, as a run's first cache open writes it.
595
- if (ignore.length > 0) await Bun.write(path.join(root, '.vx', '.gitignore'), DOT_VX_GITIGNORE)
668
+ for (const f of writes)
669
+ writeNoFollow(root, f, f.endsWith('.gitignore') ? DOT_VX_GITIGNORE : text)
596
670
  }
597
671
  const cache = REMOTE_CACHE[runner]
598
672
  const cacheLine =
@@ -735,7 +809,8 @@ async function scripts(
735
809
  }
736
810
  const unmapped = namelessNotes(root, nameless)
737
811
  // Only a workspace file this run writes declares it; one already here is the user's.
738
- const lockfile = workspaceFileAt(root) === undefined ? lockfilePlugin(root) : undefined
812
+ const lockfile =
813
+ workspaceFileAt(root) === undefined ? lockfilePlugin(root, installer(root, args)) : undefined
739
814
  const plugins =
740
815
  lockfile === undefined ? [] : [{ pkg: '@vzn/vx-lockfile', factory: lockfile.factory }]
741
816
  const plan = migrateScripts(
@@ -751,12 +826,21 @@ async function scripts(
751
826
  // to fix (M-42). Asked of the globs, not of `metas`: a nameless or
752
827
  // shared-name member is matched yet absent from `metas` (M-46).
753
828
  const globs = workspace.packageGlobs.filter((g) => g !== '.')
754
- const noMembers =
829
+ const unread =
755
830
  empty && globs.length > 0 && !(await reachesManifest(workspace))
756
- ? [
757
- `the workspace globs (${globs.map((g) => `"${g}"`).join(', ')}) match no package.json: add a package under one, or fix the glob`,
758
- ]
759
- : []
831
+ ? await undecodableMembers(workspace)
832
+ : undefined
833
+ const noMembers =
834
+ unread === undefined
835
+ ? []
836
+ : unread.length > 0
837
+ ? unread.map(
838
+ (d) =>
839
+ `${d}: the directory's name is not valid UTF-8, so its package.json cannot be read — rename it`,
840
+ )
841
+ : [
842
+ `the workspace globs (${globs.map((g) => `"${g}"`).join(', ')}) match no package.json: add a package under one, or fix the glob`,
843
+ ]
760
844
  const installed = await prepareRepo(root, [...new Set(plugins.map((p) => p.pkg))], args)
761
845
  const code = await applyMigration({
762
846
  root,
package/src/nx/index.ts CHANGED
@@ -53,18 +53,43 @@ export type {
53
53
  } from './nx-map.js'
54
54
  import { listDotenv } from './nx-dotenv.js'
55
55
  import { trackedKinds } from '../tracked-outputs.js'
56
- import { relPosix } from '../paths.js'
56
+ import { isRecord, relPosix } from '../paths.js'
57
57
  import { memberProjects, withManifests } from '../manifests.js'
58
58
  import type { AdoptionMapping } from '../mapping-cache.js'
59
59
  import { yarnrcText } from '../script-command.js'
60
+ import { lockfilePlugin } from '../workspace-notes.js'
60
61
 
61
62
  /** The snapshot's name under vx's cache dir — local to the machine, like the cache. */
62
63
  const SNAPSHOT = 'nx-project-graph.json'
63
64
  /** What the snapshot was exported from (`graphInputKey`), beside it. */
64
65
  const SNAPSHOT_KEY = 'nx-project-graph.key'
65
- /** The files at the workspace root whose edit can move Nx's graph. */
66
+ /**
67
+ * The files at the workspace root whose edit can move Nx's graph: Nx's own,
68
+ * and the tool configs an inferring `@nx/*` plugin reads (a root project's
69
+ * `jest.config.ts`, the `jest.preset.js` a project's extends).
70
+ */
66
71
  const ROOT_GRAPH_FILE =
67
- /^(nx\.json|package\.json|\.nxignore|tsconfig[^/]*\.json|pnpm-workspace\.yaml|pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?)$/
72
+ /^(nx\.json|package\.json|\.nxignore|tsconfig[^/]*\.json|pnpm-workspace\.yaml|pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|jest\.(config|preset)\.[cm]?[jt]s|\.eslintrc(\.[a-z]+)?|eslint\.config\.[cm]?[jt]s|(vite|vitest|playwright|cypress|webpack|rollup|rspack|next)\.config\.[cm]?[jt]s|vitest\.workspace\.[cm]?[jt]s)$/
73
+
74
+ /**
75
+ * Whether a root-relative path can be part of a local plugin nx.json names
76
+ * by path (`./tools/plugin.js`, `{ plugin: './plugins/obj' }`): anything in
77
+ * the plugin's directory, where the modules it imports sit; one at the root
78
+ * is the file itself, with any extension, or its directory.
79
+ */
80
+ function localPlugins(json: Record<string, unknown> | undefined): (p: string) => boolean {
81
+ const plugins = Array.isArray(json?.['plugins']) ? (json['plugins'] as unknown[]) : []
82
+ const tests = plugins.flatMap((e): ((p: string) => boolean)[] => {
83
+ const spec = typeof e === 'string' ? e : isRecord(e) ? e['plugin'] : undefined
84
+ if (typeof spec !== 'string' || !spec.startsWith('./')) return []
85
+ const file = path.posix.normalize(spec)
86
+ const dir = path.posix.dirname(file)
87
+ if (dir !== '.') return [(p) => p.startsWith(`${dir}/`)]
88
+ const stem = file.replace(/\.[^.]+$/, '')
89
+ return [(p) => p === file || p.startsWith(`${file}/`) || p.startsWith(`${stem}.`)]
90
+ })
91
+ return (p) => tests.some((t) => t(p))
92
+ }
68
93
 
69
94
  export interface NxPluginOptions {
70
95
  /**
@@ -118,6 +143,9 @@ export function nx(options: NxPluginOptions = {}): VxPlugin {
118
143
  const rootOf = (workspaceRoot: string): string =>
119
144
  options.root === undefined ? workspaceRoot : path.resolve(workspaceRoot, options.root)
120
145
  refuseExecutors(options.executors)
146
+ // The root files the workspace's plugins claim, read by `config`: a
147
+ // lockfile no plugin claims is in no key unless a task lists it.
148
+ let claimed: ReadonlySet<string> = new Set()
121
149
  // One graph load per RUN, shared by `discover` and `project`: the run
122
150
  // hands both stages the same projects array (discover's, grown by what
123
151
  // it named), so its identity is the run's, as the mapping's is.
@@ -141,17 +169,26 @@ export function nx(options: NxPluginOptions = {}): VxPlugin {
141
169
  import.meta,
142
170
  async (ctx) => {
143
171
  const root = rootOf(ctx.workspaceRoot)
172
+ const lock = lockfilePlugin(root)?.file
173
+ const unclaimed =
174
+ lock !== undefined && !(root === ctx.workspaceRoot && claimed.has(lock)) ? lock : undefined
144
175
  return mapAll(
145
176
  root,
146
177
  ctx.projects,
147
178
  await graphOf(root, ctx.cacheDir, ctx.projects),
148
179
  options.executors,
180
+ unclaimed,
149
181
  )
150
182
  },
151
183
  // At the workspace root only, as `turbo()` claims its file.
152
184
  options.root === undefined ? ['nx.json'] : [],
153
185
  {
154
186
  async config(workspace, ctx) {
187
+ claimed = new Set(
188
+ (workspace.plugins ?? []).flatMap((p) =>
189
+ p ? ((p as VxPlugin).fingerprint?.files ?? []) : [],
190
+ ),
191
+ )
155
192
  const root = rootOf(ctx.workspaceRoot)
156
193
  const nxBaseCommit = Bun.env['NX_BASE']?.trim()
157
194
  if (nxBaseCommit) ctx.setBranch({ base: nxBaseCommit })
@@ -305,6 +342,7 @@ async function mapAll(
305
342
  projects: readonly ProjectMeta[],
306
343
  loaded: LoadedGraph,
307
344
  executors: NxExecutors | undefined,
345
+ lockfile: string | undefined,
308
346
  ): Promise<AdoptionRun> {
309
347
  const metas = await withManifests(projects)
310
348
  const { text, graph, notes } = loaded
@@ -312,11 +350,12 @@ async function mapAll(
312
350
  // The mapping is cached on what it read; a translator is code it ran.
313
351
  if (executors !== undefined)
314
352
  reads.push(JSON.stringify(Object.entries(executors).map(([k, fn]) => [k, String(fn)])))
353
+ reads.push(`unclaimed lockfile: ${lockfile ?? ''}`)
315
354
  return {
316
355
  name: 'nx',
317
356
  spareTracked: true,
318
357
  reads,
319
- map: (tracked) => index(root, metas, graph, notes, tracked, executors),
358
+ map: (tracked) => index(root, metas, graph, notes, tracked, executors, lockfile),
320
359
  }
321
360
  }
322
361
 
@@ -335,7 +374,7 @@ const textOf = (file: string): Promise<string> =>
335
374
  * and the package.json of each graph node no package matches (its
336
375
  * synthetic project's name), the `.env` names in every project dir,
337
376
  * NX_LOAD_DOT_ENV_FILES, whether the bins the tasks run are installed,
338
- * and the graph load's own notes.
377
+ * which root tsconfig exists, and the graph load's own notes.
339
378
  */
340
379
  async function nxReads(
341
380
  root: string,
@@ -379,6 +418,13 @@ async function nxReads(
379
418
  ...(await Promise.all(
380
419
  ['package.json', '.npmrc', 'pnpm-workspace.yaml'].map((f) => textOf(path.join(root, f))),
381
420
  )),
421
+ // Which root tsconfig every task keys (`rootTsConfig`), decided by
422
+ // existence: an untracked new one changes no git index.
423
+ JSON.stringify(
424
+ await Promise.all(
425
+ ['tsconfig.base.json', 'tsconfig.json'].map((f) => Bun.file(path.join(root, f)).exists()),
426
+ ),
427
+ ),
382
428
  ...notes,
383
429
  ]
384
430
  }
@@ -390,6 +436,7 @@ async function index(
390
436
  loadNotes: readonly string[],
391
437
  trackedFiles: () => Promise<readonly string[] | null>,
392
438
  executors: NxExecutors | undefined,
439
+ lockfile: string | undefined,
393
440
  ): Promise<AdoptionMapping> {
394
441
  const notes: string[] = []
395
442
  const live = [...loadNotes]
@@ -407,6 +454,7 @@ async function index(
407
454
  ...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
408
455
  ownConfig: (rel) => configs.get(relRoot(rel)) ?? null,
409
456
  ...(executors === undefined ? {} : { executors }),
457
+ ...(lockfile === undefined ? {} : { lockfile }),
410
458
  })
411
459
  notes.push(...mapped.notes)
412
460
  const byName = new Map<string, GeneratedProject>()
@@ -587,21 +635,23 @@ async function graphInputKey(
587
635
  // A `.gitignore` anywhere too: Nx's project walk honours each one, so an
588
636
  // edit between project roots hides or shows a project.
589
637
  const roots = dirs.map((d) => relPosix(root, d))
638
+ // A manifest needs no read of its own: git lists it when it is edited, and
639
+ // HEAD moves when an edit is committed (reading all 2,000 cost 50 ms at
640
+ // 1,000 projects). nx.json's chain does: a base may sit in node_modules,
641
+ // where git does not look.
642
+ const nxJson = await readNxJson(root).catch(() => null)
643
+ const plugin = localPlugins(nxJson?.json)
590
644
  const graphFile = (p: string): boolean =>
591
645
  p === 'project.json' ||
592
646
  p.endsWith('/project.json') ||
593
647
  p === '.gitignore' ||
594
648
  p.endsWith('/.gitignore') ||
595
649
  roots.some((r) => r === '' || p.startsWith(`${r}/`)) ||
596
- (!p.includes('/') && ROOT_GRAPH_FILE.test(p))
650
+ (!p.includes('/') && ROOT_GRAPH_FILE.test(p)) ||
651
+ plugin(p)
597
652
  const listed = [
598
653
  ...new Set(changed.filter((p) => !inCache(p) && !nxCache(p) && !vxDir(p) && graphFile(p))),
599
654
  ]
600
- // A manifest needs no read of its own: git lists it when it is edited, and
601
- // HEAD moves when an edit is committed (reading all 2,000 cost 50 ms at
602
- // 1,000 projects). nx.json's chain does: a base may sit in node_modules,
603
- // where git does not look.
604
- const nxJson = await readNxJson(root).catch(() => null)
605
655
  const files = [
606
656
  ...(nxJson?.files ?? [path.join(root, 'nx.json')]),
607
657
  ...listed.sort().map((p) => path.join(root, p)),
@@ -7,10 +7,13 @@
7
7
  // (`dependentTasksOutputFiles`) is nothing; `externalDependencies` is a todo saying
8
8
  // so. Extracted from `buildTask` in item 606.
9
9
 
10
- import path from 'node:path'
11
10
  import { nxWorkspacePath, underProject, withProjectDir } from './nx-outputs.js'
12
- import type { TrackedKinds } from '../tracked-outputs.js'
13
- import { gluedGlobstarAsStar, minimatchToVx, nxBareGroups } from '../glob-grammar.js'
11
+ import {
12
+ apartTakeBacks,
13
+ gluedGlobstarAsStar,
14
+ minimatchToVx,
15
+ nxBareGroups,
16
+ } from '../glob-grammar.js'
14
17
  import { shellQuote } from '../nx-command.js'
15
18
 
16
19
  export interface NxInputs {
@@ -37,6 +40,8 @@ export interface NxInputs {
37
40
  }>
38
41
  /** `{ dependentTasksOutputFiles }` globs: keyed only through a `dependsOn` edge. */
39
42
  readonly dependentOutputs: string[]
43
+ /** Those of them marked `transitive`: read from every task below, not only the direct ones. */
44
+ readonly transitiveOutputs: string[]
40
45
  }
41
46
 
42
47
  export function emptyNxInputs(): NxInputs {
@@ -47,6 +52,7 @@ export function emptyNxInputs(): NxInputs {
47
52
  runtimeCmds: [],
48
53
  upstream: [],
49
54
  dependentOutputs: [],
55
+ transitiveOutputs: [],
50
56
  }
51
57
  }
52
58
 
@@ -78,14 +84,19 @@ export function expandNxInputs(
78
84
  readonly rel: string
79
85
  readonly name: string
80
86
  readonly outputs?: readonly string[]
81
- /** git's tracked files by directory; unknown, every output counts as ignored. */
82
- readonly tracked?: (rel: string) => TrackedKinds
83
- /** The root package.json's dependency names: every task's key holds them. */
87
+ /** The root-relative outputs git ignores; unknown, every output counts as ignored. */
88
+ readonly ignoredOutputs?: ReadonlySet<string>
89
+ /** The root package.json's dependency names: a lockfile plugin keys them into every task. */
84
90
  readonly rootDeps?: ReadonlySet<string>
91
+ /** The root lockfile no plugin claims, which nothing keys unless a task lists it. */
92
+ readonly lockfile?: string
85
93
  },
86
94
  into: NxInputs,
87
95
  todos: string[],
88
96
  ): void {
97
+ // A `{workspaceRoot}` path into the project, kept apart from the
98
+ // project's own set until the end (see there).
99
+ const wsOwn: string[] = []
89
100
  const expand = (entry: unknown, seen: Set<string>): void => {
90
101
  if (typeof entry === 'string') {
91
102
  let s = entry
@@ -113,14 +124,7 @@ export function expandNxInputs(
113
124
  // An output git tracks (a generator writing into `src`) is hashed,
114
125
  // and dropping it keyed `build` on no file (stale hit, 2026-10-28).
115
126
  const lit = withProjectDir(p, at.rel, false)
116
- const ignored = (o: string): boolean => {
117
- if (at.tracked === undefined) return true
118
- const dir = path.posix.dirname(o)
119
- return (
120
- at.tracked(o).files.length === 0 &&
121
- !at.tracked(dir).files.includes(dir === '.' ? o : o.slice(dir.length + 1))
122
- )
123
- }
127
+ const ignored = (o: string): boolean => at.ignoredOutputs?.has(o) ?? true
124
128
  if (
125
129
  neg === '' &&
126
130
  at.outputs?.some((o) => (lit === o || lit.startsWith(`${o}/`)) && ignored(o))
@@ -141,7 +145,10 @@ export function expandNxInputs(
141
145
  ? null
142
146
  : underProject(g, at.rel)
143
147
  if (own === null) into.wsFiles.push(neg + withProjectDir(g, at.rel, true))
144
- else into.files.push(neg + (own === '' ? '**/*' : withProjectDir(own, at.rel, true)))
148
+ else
149
+ (s.startsWith('{workspaceRoot}') ? wsOwn : into.files).push(
150
+ neg + (own === '' ? '**/*' : withProjectDir(own, at.rel, true)),
151
+ )
145
152
  return
146
153
  }
147
154
  // Bare string = named-input reference.
@@ -192,18 +199,23 @@ export function expandNxInputs(
192
199
  return
193
200
  }
194
201
  if (o.externalDependencies !== undefined) {
195
- // Not the project's package.json: Nx's inferred targets name root
196
- // devDependencies (eslint, vitest), which only the lockfile keys.
197
- // One the root declares is keyed already, with or without a
198
- // lockfile plugin (it keys the root's dependencies): nothing to
199
- // say. The todo sat on 32 tasks of analogjs for `eslint`.
202
+ // Nx hashes each named package's resolved version, and core keys no
203
+ // lockfile. With no plugin claiming the file, the task keys it whole,
204
+ // a superset: a `left-pad` bump was a hit where Nx re-ran. A lockfile
205
+ // plugin keys the project's and the root's dependencies, so a name
206
+ // the root declares is keyed there: the todo sat on 32 tasks of
207
+ // analogjs for `eslint`. Nx's inferred targets name root
208
+ // devDependencies (eslint, vitest), not the project's package.json.
200
209
  const names = Array.isArray(o.externalDependencies) ? o.externalDependencies : []
210
+ if (at.lockfile !== undefined) {
211
+ expand(`{workspaceRoot}/${at.lockfile}`, seen)
212
+ return
213
+ }
201
214
  if (names.length > 0 && names.every((n) => typeof n === 'string' && at.rootDeps?.has(n)))
202
215
  return
203
216
  todos.push(
204
- `input {externalDependencies: ${JSON.stringify(o.externalDependencies)}}: vx keys ` +
205
- 'every task on the lockfile (the whole file, or with a @vzn/vx-lockfile plugin the ' +
206
- "project's and the root's dependencies) — safe to drop unless only another project installs one",
217
+ `input {externalDependencies: ${JSON.stringify(o.externalDependencies)}}: a @vzn/vx-lockfile ` +
218
+ "plugin keys each task on its project's and the root's dependencies — one only another project installs is not keyed",
207
219
  )
208
220
  return
209
221
  }
@@ -214,7 +226,9 @@ export function expandNxInputs(
214
226
  // The mapper checks an edge is left to carry them.
215
227
  if (o.dependentTasksOutputFiles !== undefined) {
216
228
  const g = o.dependentTasksOutputFiles
217
- into.dependentOutputs.push(typeof g === 'string' ? g : JSON.stringify(g))
229
+ const glob = typeof g === 'string' ? g : JSON.stringify(g)
230
+ into.dependentOutputs.push(glob)
231
+ if (o.transitive === true) into.transitiveOutputs.push(glob)
218
232
  return
219
233
  }
220
234
  // `{ workingDirectory }` hashes where Nx was started (`cwd:relative`
@@ -276,6 +290,16 @@ export function expandNxInputs(
276
290
  )
277
291
  }
278
292
  for (const entry of entries) expand(entry, new Set())
293
+ // Nx hashes the `{projectRoot}` and `{workspaceRoot}` filesets as two
294
+ // sets, a `!` acting only inside its own; core subtracts every `!` in
295
+ // `files` from every entry. Production's `!{projectRoot}/jest.config.[jt]s`
296
+ // took out a target's `{workspaceRoot}/libs/a/jest.config.ts`, and an
297
+ // edit to it replayed (stale hit). A `!` that may reach the other set
298
+ // goes: more keyed, never less.
299
+ if (wsOwn.length > 0) {
300
+ const [own, ws] = apartTakeBacks(into.files, wsOwn)
301
+ into.files.splice(0, into.files.length, ...own, ...ws)
302
+ }
279
303
  // Nx matches a project fileset with no positive glob against every project
280
304
  // file, the negations taken out: nx-recipes' `noMarkdown` is
281
305
  // `["!{projectRoot}/**/*.md"]`, and a `.tsx` edit re-runs its build while a