@vzn/vx 0.0.287 → 0.0.289

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.287",
3
+ "version": "0.0.289",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.287",
21
- "@vzn/vx-linux-arm64": "0.0.287",
22
- "@vzn/vx-darwin-x64": "0.0.287",
23
- "@vzn/vx-darwin-arm64": "0.0.287"
20
+ "@vzn/vx-linux-x64": "0.0.289",
21
+ "@vzn/vx-linux-arm64": "0.0.289",
22
+ "@vzn/vx-darwin-x64": "0.0.289",
23
+ "@vzn/vx-darwin-arm64": "0.0.289"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -29,6 +29,7 @@
29
29
  import { existsSync, readFileSync } from 'node:fs'
30
30
  import path from 'node:path'
31
31
  import { taskNameProblem } from './config-schema.js'
32
+ import { buildPackageGraph } from './package-graph.js'
32
33
  import type { ProjectMeta } from './workspace.js'
33
34
  import {
34
35
  foldScriptHooks,
@@ -377,6 +378,7 @@ export function migrateScripts(
377
378
  projects.push({ name: meta.name, dir: meta.dir, importLines, tasks })
378
379
  }
379
380
  }
381
+ breakBuildCycles(projects, metas)
380
382
  return {
381
383
  headerNotes: [
382
384
  // Where to add one is the TODO's to say, when there is one: an Nx repo
@@ -390,6 +392,95 @@ export function migrateScripts(
390
392
  }
391
393
  }
392
394
 
395
+ /**
396
+ * pnpm sorts a dependency cycle away and builds anyway; vx's `^build`
397
+ * refuses it, so the config init wrote failed its first run (nuxt:
398
+ * `@nuxt/nitro-server` devDepends on `nuxt`, which depends on it; vitest's
399
+ * browser packages). A package in a cycle of builds waits, instead of on
400
+ * `^build`, on each build outside its cycle that `^build` reaches (through
401
+ * packages with none, as core walks it), with a TODO to order the cycle.
402
+ */
403
+ function breakBuildCycles(projects: GeneratedProject[], metas: readonly ProjectMeta[]): void {
404
+ const builds = new Set(
405
+ projects
406
+ .filter((p) => p.tasks.some((t) => t.name === 'build' && t.task !== null))
407
+ .map((p) => p.name),
408
+ )
409
+ if (builds.size < 2) return
410
+ const graph = buildPackageGraph([...metas])
411
+ const holders = new Map<string, string[]>()
412
+ for (const name of builds) {
413
+ const found = new Set<string>()
414
+ const visited = new Set([name])
415
+ const frontier = [...graph.directDeps(name)]
416
+ while (frontier.length > 0) {
417
+ const at = frontier.pop()!
418
+ if (visited.has(at)) continue
419
+ visited.add(at)
420
+ if (builds.has(at)) found.add(at)
421
+ else frontier.push(...graph.directDeps(at))
422
+ }
423
+ holders.set(name, [...found].sort())
424
+ }
425
+ for (const cycle of cyclesOf(holders)) {
426
+ const members = new Set(cycle)
427
+ for (const p of projects) {
428
+ if (!members.has(p.name)) continue
429
+ const at = p.tasks.find(
430
+ (t) =>
431
+ Array.isArray(t.task?.['dependsOn']) &&
432
+ (t.task['dependsOn'] as string[]).includes('^build'),
433
+ )
434
+ if (at === undefined) continue
435
+ const outside = holders
436
+ .get(p.name)!
437
+ .filter((h) => !members.has(h))
438
+ .map((h) => `${h}#build`)
439
+ at.task!['dependsOn'] = (at.task!['dependsOn'] as string[]).flatMap((d) =>
440
+ d === '^build' ? outside : [d],
441
+ )
442
+ at.todos.push(
443
+ `its package is in a dependency cycle (${cycle.join(', ')}), where \`^build\` would refuse the run — it waits on the builds outside the cycle; order the ones inside it by hand`,
444
+ )
445
+ }
446
+ }
447
+ }
448
+
449
+ /** The strongly connected components of more than one node (Tarjan), each sorted. */
450
+ function cyclesOf(edges: ReadonlyMap<string, readonly string[]>): string[][] {
451
+ const index = new Map<string, number>()
452
+ const low = new Map<string, number>()
453
+ const stack: string[] = []
454
+ const onStack = new Set<string>()
455
+ const out: string[][] = []
456
+ const visit = (v: string): void => {
457
+ index.set(v, index.size)
458
+ low.set(v, index.get(v)!)
459
+ stack.push(v)
460
+ onStack.add(v)
461
+ for (const w of edges.get(v) ?? []) {
462
+ if (!index.has(w)) {
463
+ visit(w)
464
+ low.set(v, Math.min(low.get(v)!, low.get(w)!))
465
+ } else if (onStack.has(w)) {
466
+ low.set(v, Math.min(low.get(v)!, index.get(w)!))
467
+ }
468
+ }
469
+ if (low.get(v) === index.get(v)) {
470
+ const comp: string[] = []
471
+ let w: string
472
+ do {
473
+ w = stack.pop()!
474
+ onStack.delete(w)
475
+ comp.push(w)
476
+ } while (w !== v)
477
+ if (comp.length > 1) out.push(comp.sort())
478
+ }
479
+ }
480
+ for (const v of [...edges.keys()].sort()) if (!index.has(v)) visit(v)
481
+ return out
482
+ }
483
+
393
484
  /** The member whose directory holds every other member's, if one does. */
394
485
  function workspaceRootOf(metas: readonly ProjectMeta[]): ProjectMeta | undefined {
395
486
  const outer = metas.reduce((a, b) => (b.dir.length < a.dir.length ? b : a))
@@ -558,8 +558,7 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
558
558
  }),
559
559
  )
560
560
 
561
- const projects: ProjectMeta[] = []
562
- const seenName = new Map<string, string>()
561
+ const byName = new Map<string, NonNullable<(typeof loaded)[number]>[]>()
563
562
  for (const entry of loaded) {
564
563
  if (entry === null) continue
565
564
  const { dir, pkg, configPath } = entry
@@ -575,22 +574,41 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
575
574
  }
576
575
  continue
577
576
  }
578
- const previous = seenName.get(pkg.name)
579
- if (previous) {
580
- // pnpm accepts two manifests of one name (sveltejs/kit's test apps,
581
- // the first-five-minutes walk, 2026-09-28); vx cannot, since a
582
- // project is addressed by it. Say which two, short, and the way on.
577
+ const group = byName.get(pkg.name)
578
+ if (group === undefined) byName.set(pkg.name, [entry])
579
+ else group.push(entry)
580
+ }
581
+ const projects: ProjectMeta[] = []
582
+ const shared: string[] = []
583
+ for (const [name, group] of byName) {
584
+ if (group.length === 1) {
585
+ const { dir, pkg, configPath } = group[0]!
586
+ projects.push({ name, dir, packageJson: pkg, configPath })
587
+ continue
588
+ }
589
+ const dirs = group.map((e) => relPosix(workspace.root, e.dir)).sort()
590
+ // pnpm accepts two manifests of one name (vite's playground, sveltejs/kit's
591
+ // test apps); vx cannot, since a project is addressed by it. Like a
592
+ // nameless one, a pair that declares no vx tasks is left out, so the
593
+ // rest of the workspace runs (vite was refused whole, 2026-10-01); one
594
+ // with a vx config was meant to run, and is refused with the way on.
595
+ if (group.some((e) => e.configPath !== null)) {
583
596
  throw new UserError(
584
- `Duplicate package name "${pkg.name}" in workspace: ${[previous, dir]
585
- .map((d) => relPosix(workspace.root, d))
586
- .sort()
587
- .join(' and ')}; ` +
597
+ `Duplicate package name "${name}" in workspace: ${dirs.join(' and ')}; ` +
588
598
  'vx names a project by its package name — rename one, or leave one out with a `!` pattern in the workspace globs',
589
599
  )
590
600
  }
591
- seenName.set(pkg.name, dir)
592
- projects.push({ name: pkg.name, dir, packageJson: pkg, configPath })
601
+ shared.push(`${name} (${dirs.join(', ')})`)
602
+ }
603
+ if (shared.length > 0) {
604
+ shared.sort()
605
+ const shown =
606
+ shared.slice(0, 2).join('; ') + (shared.length > 2 ? `; and ${shared.length - 2} more` : '')
607
+ process.stderr.write(
608
+ `vx: left out, as no project can be addressed by a name several manifests share (none has a vx config): ${shown}\n`,
609
+ )
593
610
  }
611
+
594
612
  // Code-unit order, not `localeCompare`: ICU collation cost 28 ms of a
595
613
  // 300 ms warm run at 1000 projects, and a stable deterministic order is
596
614
  // all any consumer needs.