@vzn/vx 0.0.23 → 0.0.25

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.23",
3
+ "version": "0.0.25",
4
4
  "description": "An open, extensible monorepo task runner.",
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.23",
21
- "@vzn/vx-linux-arm64": "0.0.23",
22
- "@vzn/vx-darwin-x64": "0.0.23",
23
- "@vzn/vx-darwin-arm64": "0.0.23"
20
+ "@vzn/vx-linux-x64": "0.0.25",
21
+ "@vzn/vx-linux-arm64": "0.0.25",
22
+ "@vzn/vx-darwin-x64": "0.0.25",
23
+ "@vzn/vx-darwin-arm64": "0.0.25"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/watch.ts CHANGED
@@ -549,11 +549,13 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
549
549
  // scope is the one resolved at start; a new package joins it only as a
550
550
  // dependency of it.
551
551
  rediscover: async () => {
552
- const all = await listProjects(await loadWorkspace(workspaceRoot))
552
+ const workspace = await loadWorkspace(workspaceRoot)
553
+ const all = await listProjects(workspace)
553
554
  const sweep = await sweepConfigs(all, workspaceRoot, load)
554
555
  const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
555
556
  return {
556
557
  projects: now,
558
+ memberBases: memberBaseDirs(workspace),
557
559
  workspaceWide: sweep.workspaceWide,
558
560
  workspaceInputs: sweep.workspaceInputs,
559
561
  outputs: sweep.outputs,
@@ -762,7 +764,10 @@ export function makeRootEventFilter(
762
764
  // this line load-bearing.
763
765
  if (
764
766
  !rel.includes('/') &&
765
- (isWorkspaceFingerprintFile(rel) || isWorkspaceConfigFile(rel) || claimedRootFiles.has(rel))
767
+ (isWorkspaceFingerprintFile(rel) ||
768
+ isWorkspaceConfigFile(rel) ||
769
+ rel === 'package.json' ||
770
+ claimedRootFiles.has(rel))
766
771
  ) {
767
772
  return true
768
773
  }
@@ -804,7 +809,7 @@ interface WatchLoopArgs {
804
809
  * loaded once per process, so the set is fixed for the loop.
805
810
  */
806
811
  claimedRootFiles: ReadonlySet<string>
807
- /** The directory each `<dir>/*` package glob names; a member coming or going there is a cycle. */
812
+ /** The directory each `<dir>/*` package glob names at start; a member coming or going there is a cycle. */
808
813
  memberBases: readonly string[]
809
814
  /** Every package's directory, in scope or not: a member base's other entries are packages still to come. */
810
815
  packageDirs: ReadonlySet<string>
@@ -814,6 +819,7 @@ interface WatchLoopArgs {
814
819
 
815
820
  interface Rediscovered {
816
821
  projects: readonly ProjectMeta[]
822
+ memberBases: readonly string[]
817
823
  workspaceWide: boolean
818
824
  workspaceInputs: readonly string[]
819
825
  outputs: ReadonlyMap<string, readonly string[]>
@@ -833,21 +839,29 @@ const PROJECT_CONFIGS: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
833
839
  * watcher, a new output must stop being an event) or the workspace config
834
840
  * (a plugin's `project` stage). Until item 891 only a member directory
835
841
  * coming or going re-read the set, so each of these waited for a restart
836
- * while the loop looked alive.
842
+ * while the loop looked alive. A root fingerprint file too, for
843
+ * `pnpm-workspace.yaml`: a glob added there was a cycle that ran the new
844
+ * packages and watched none of them (item 1018).
837
845
  */
838
846
  function shapesWatchedSet(filename: string): boolean {
839
847
  const base = path.basename(filename)
840
- return base === 'package.json' || PROJECT_CONFIGS.has(base) || isWorkspaceConfigFile(base)
848
+ return (
849
+ base === 'package.json' ||
850
+ isWorkspaceFingerprintFile(base) ||
851
+ PROJECT_CONFIGS.has(base) ||
852
+ isWorkspaceConfigFile(base)
853
+ )
841
854
  }
842
855
 
843
856
  const CLOSED: WatchHandle = { close() {} }
844
857
 
845
858
  async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
846
- const { opts, stop, workspaceRoot, projects, cacheDir, memberBases } = args
859
+ const { opts, stop, workspaceRoot, projects, cacheDir } = args
847
860
  // The watched set as of the last cycle: `rearm` replaces these after an
848
861
  // event that can change it, and every filter below reads the current one.
849
862
  let workspaceWide = args.workspaceWide
850
863
  let packageDirs = args.packageDirs
864
+ let memberBases = args.memberBases
851
865
  let projectDirs = args.projectDirs
852
866
  let workspaceInputs = args.workspaceInputs
853
867
  let outputs = args.outputs
@@ -1185,6 +1199,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
1185
1199
  if (
1186
1200
  isWorkspaceFingerprintFile(filename) ||
1187
1201
  isWorkspaceConfigFile(filename) ||
1202
+ filename === 'package.json' ||
1188
1203
  args.claimedRootFiles.has(filename)
1189
1204
  ) {
1190
1205
  if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
@@ -1334,6 +1349,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
1334
1349
  configImportFiles = next.configImports
1335
1350
  wsConfigImportFiles = next.workspaceConfigImports
1336
1351
  packageDirs = next.packageDirs
1352
+ memberBases = next.memberBases
1353
+ armBases()
1337
1354
  isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
1338
1355
  matters = makeRootEventFilter(
1339
1356
  workspaceRoot,
@@ -1373,28 +1390,45 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
1373
1390
  // triggers re-reads the workspace (`rearm`) so the new package's own
1374
1391
  // edits are cycles from then on. Until 2026-09-10 the watched set was
1375
1392
  // fixed when the loop armed: the next cycle ran the new package, and
1376
- // every edit inside it after that was silence.
1377
- for (const base of memberBases) {
1378
- let members = memberEntries(base)
1379
- try {
1380
- arm(base, false, (filename) => {
1381
- if (isIgnoredWatchPath(filename)) return
1382
- // Only a member coming or going. On macOS a non-recursive watcher
1383
- // also reports a member whose CONTENTS changed (FSEvents names the
1384
- // directory a write landed in), so a task writing into its own
1385
- // project — or the arm's own probe file — read as a member event and
1386
- // cost an uncached task one execution per cycle (CI, 2026-09-10).
1387
- const now = memberEntries(base)
1388
- if (sameMembers(members, now)) return
1389
- members = now
1390
- reread = true
1391
- trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
1392
- })
1393
- } catch (err) {
1394
- const msg = err instanceof Error ? err.message : String(err)
1395
- process.stderr.write(`vx watch: cannot watch ${base}: ${msg}\n`)
1393
+ // every edit inside it after that was silence. The bases themselves are
1394
+ // re-read with the set: a glob added to the list watched nothing new
1395
+ // until a restart (item 1018).
1396
+ const baseArms = new Map<string, WatchHandle>()
1397
+ const armBases = (): void => {
1398
+ const want = new Set(memberBases)
1399
+ for (const [base, handle] of baseArms) {
1400
+ if (want.has(base)) continue
1401
+ handle.close()
1402
+ baseArms.delete(base)
1403
+ }
1404
+ for (const base of want) {
1405
+ if (baseArms.has(base)) continue
1406
+ let members = memberEntries(base)
1407
+ try {
1408
+ baseArms.set(
1409
+ base,
1410
+ arm(base, false, (filename) => {
1411
+ if (isIgnoredWatchPath(filename)) return
1412
+ // Only a member coming or going. On macOS a non-recursive watcher
1413
+ // also reports a member whose CONTENTS changed (FSEvents names the
1414
+ // directory a write landed in), so a task writing into its own
1415
+ // project — or the arm's own probe file — read as a member event
1416
+ // and cost an uncached task one execution per cycle (CI,
1417
+ // 2026-09-10).
1418
+ const now = memberEntries(base)
1419
+ if (sameMembers(members, now)) return
1420
+ members = now
1421
+ reread = true
1422
+ trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
1423
+ }),
1424
+ )
1425
+ } catch (err) {
1426
+ const msg = err instanceof Error ? err.message : String(err)
1427
+ process.stderr.write(`vx watch: cannot watch ${base}: ${msg}\n`)
1428
+ }
1396
1429
  }
1397
1430
  }
1431
+ armBases()
1398
1432
  armPending(false)
1399
1433
  armImports()
1400
1434
 
@@ -30,6 +30,13 @@ export interface TaskNode {
30
30
  config: TaskConfig
31
31
  /** Ids of tasks that must complete before this one runs. */
32
32
  deps: string[]
33
+ /**
34
+ * The members of `deps` that order the run and fold into no key:
35
+ * `--exclude-dependencies` dropped a task between this one and them
36
+ * (`excludeDependencies`). The dropped task's derived key already folds
37
+ * theirs, so folding them again would give a key no full run derives.
38
+ */
39
+ orderOnly?: string[]
33
40
  /**
34
41
  * True for the tasks the user actually asked for (via cwd, `--all`,
35
42
  * `--filter`, or `pkg#task`). False for deps pulled in by `dependsOn`
@@ -588,6 +595,27 @@ export function excludeDependencies(
588
595
  if (lost.length === 0) continue
589
596
  dropped.set(id, lost)
590
597
  node.deps = node.deps.filter(stays)
598
+ // A scheduled task reached through a dropped one still runs first: in
599
+ // `test → gen → build` with `gen` excluded and `build` requested, the
600
+ // edge to `gen` went and nothing ordered `test` after `build`, so the
601
+ // two ran at once (item 1019). The walk reads unscheduled nodes only,
602
+ // whose deps this loop leaves as they are.
603
+ const order = new Set<string>()
604
+ const seen = new Set<string>()
605
+ const stack = [...lost]
606
+ while (stack.length > 0) {
607
+ const dep = stack.pop()!
608
+ if (seen.has(dep)) continue
609
+ seen.add(dep)
610
+ if (scheduled.has(dep)) {
611
+ if (!node.deps.includes(dep)) order.add(dep)
612
+ continue
613
+ }
614
+ stack.push(...nodes.get(dep)!.deps)
615
+ }
616
+ if (order.size === 0) continue
617
+ node.orderOnly = [...order].sort()
618
+ node.deps = [...node.deps, ...node.orderOnly].sort()
591
619
  }
592
620
  for (const id of keyOnly.keys()) nodes.delete(id)
593
621
  return { keyOnly, dropped }
@@ -16,7 +16,7 @@
16
16
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
17
17
  import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
18
18
  import { computeGroupHash, computeTaskHash, type HashCache } from './task-hash.js'
19
- import { filterUpstreamHashes } from './upstream.js'
19
+ import { filterUpstreamHashes, keyedDeps } from './upstream.js'
20
20
 
21
21
  export interface KeyExcludedArgs {
22
22
  /** The scheduled graph, after `excludeDependencies`. */
@@ -41,7 +41,7 @@ export interface KeyExcludedArgs {
41
41
  export async function keyExcludedDependencies(args: KeyExcludedArgs): Promise<void> {
42
42
  const nodeOf = (id: string): TaskNode => args.nodes.get(id) ?? args.keyOnly.get(id)!
43
43
  const fullDeps = (node: TaskNode): readonly string[] => [
44
- ...node.deps,
44
+ ...keyedDeps(node),
45
45
  ...(args.dropped.get(node.id) ?? []),
46
46
  ]
47
47
  // Each key's promise is made after its dependencies' promises, in a
@@ -17,7 +17,15 @@ import { UserError } from '../util/index.js'
17
17
  * a key at one of them and not the others.
18
18
  */
19
19
  export function keyUpstream(node: TaskNode, upstream: TaskOutcome[]): TaskOutcome[] {
20
- return node.excludedUpstream === undefined ? upstream : [...upstream, ...node.excludedUpstream]
20
+ const order = node.orderOnly
21
+ const keyed = order === undefined ? upstream : upstream.filter((o) => !order.includes(o.node.id))
22
+ return node.excludedUpstream === undefined ? keyed : [...keyed, ...node.excludedUpstream]
23
+ }
24
+
25
+ /** `node.deps` less its ordering-only edges: the dependencies a key may fold. */
26
+ export function keyedDeps(node: TaskNode): readonly string[] {
27
+ const order = node.orderOnly
28
+ return order === undefined ? node.deps : node.deps.filter((d) => !order.includes(d))
21
29
  }
22
30
 
23
31
  /**