@vzn/vx 0.0.589 → 0.0.591

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.589",
3
+ "version": "0.0.591",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.589",
25
- "@vzn/vx-linux-arm64": "0.0.589",
26
- "@vzn/vx-darwin-x64": "0.0.589",
27
- "@vzn/vx-darwin-arm64": "0.0.589"
24
+ "@vzn/vx-linux-x64": "0.0.591",
25
+ "@vzn/vx-linux-arm64": "0.0.591",
26
+ "@vzn/vx-darwin-x64": "0.0.591",
27
+ "@vzn/vx-darwin-arm64": "0.0.591"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/select.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  claimedAffected,
32
32
  fingerprintClaims,
33
33
  gitOfDiscovery,
34
+ hasHook,
34
35
  isDefaultBuild,
35
36
  keepDiscoveryGraph,
36
37
  } from '../orchestrator/index.js'
@@ -300,6 +301,15 @@ export async function resolveFilters(
300
301
  if (f.raw === affected) {
301
302
  changes = await affectedChanges(args)
302
303
  names = changes.projects
304
+ // A `graph` plugin may tie any task to a changed path (an edge, an
305
+ // input), and the run alone builds that graph: every project is a
306
+ // candidate, and the run keeps the tasks its final graph reaches.
307
+ if (
308
+ changes.changed.length > 0 &&
309
+ hasHook((await loadCliWorkspace(root)).plugins, 'graph')
310
+ ) {
311
+ names = new Set(projects.map((p) => p.name))
312
+ }
303
313
  } else {
304
314
  names = await affectedProjects(args)
305
315
  }
@@ -88,8 +88,13 @@ export function affectedRoots(
88
88
  return false
89
89
  }
90
90
  const seeded = (n: TaskNode): boolean => {
91
- if (!changes.projects.has(n.projectName)) return false
92
91
  const cache = n.config.cache
92
+ // Asked of every node, not only the changed projects' (the
93
+ // `workspaceFiles` owners): a `graph` hook may have given the glob.
94
+ if (cache !== undefined && changes.changed.some((rel) => declaresInput(cache, null, rel))) {
95
+ return true
96
+ }
97
+ if (!changes.projects.has(n.projectName)) return false
93
98
  // A group runs nothing; only the default `build` (projects.ts) is keyed.
94
99
  // An uncached task reads its project: a root file another task of it
95
100
  // declares (`workspaceFiles`) is no change there.
@@ -97,10 +102,7 @@ export function affectedRoots(
97
102
  return !isGroupTask(n) && (whole.has(n.projectName) || changes.paths.has(n.projectName))
98
103
  }
99
104
  if (whole.has(n.projectName)) return true
100
- if ((changes.paths.get(n.projectName) ?? []).some((rel) => declaresInput(cache, rel, null))) {
101
- return true
102
- }
103
- return changes.changed.some((rel) => declaresInput(cache, null, rel))
105
+ return (changes.paths.get(n.projectName) ?? []).some((rel) => declaresInput(cache, rel, null))
104
106
  }
105
107
  const reached = new Map<string, boolean>()
106
108
  const reaches = (id: string): boolean => {
@@ -294,7 +294,11 @@ export interface ProjectHookContext extends BaseContext {
294
294
  }
295
295
 
296
296
  export interface GraphHookContext extends BaseContext {
297
- /** Task ids the user asked for (the rest were pulled in by `dependsOn`). */
297
+ /**
298
+ * Task ids the user asked for (the rest were pulled in by `dependsOn`).
299
+ * Under `--affected` this is every candidate: the selection reads the
300
+ * graph the hooks leave and cuts it down afterwards.
301
+ */
298
302
  readonly requested: readonly string[]
299
303
  }
300
304
 
@@ -521,9 +521,43 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
521
521
  : {}),
522
522
  })
523
523
  let nodes = graphOf(requested)
524
+ const hasGraphHook = hasHook(plugins, 'graph')
525
+ const graphStage = async (): Promise<void> => {
526
+ // A hook edits each node's config in place, as `project` does, so the
527
+ // edit is held to what the loader accepts from a user: a misspelled
528
+ // `exec` field ran as an empty command and failed with no reason.
529
+ const configPaths = new Map(projectMetas.map((m) => [m.name, m.configPath]))
530
+ await applyGraphHooks(
531
+ plugins,
532
+ nodes,
533
+ {
534
+ workspaceRoot,
535
+ cacheDir,
536
+ warn: (m) => log.status(m),
537
+ requested: [...nodes.values()].filter((n) => n.requested).map((n) => n.id),
538
+ },
539
+ (plugin) => {
540
+ for (const n of nodes.values()) {
541
+ if (isDefaultBuild(n.config)) continue
542
+ const where = configPaths.get(n.projectName) ?? `${n.projectName} (no config file)`
543
+ validateProjectConfig(
544
+ { tasks: { [n.taskName]: n.config } },
545
+ `${where} (after plugin '${plugin.name}')`,
546
+ )
547
+ }
548
+ },
549
+ workspaceConfig?.rules,
550
+ )
551
+ }
552
+ // `--affected` selects over the FINAL graph: an edge or an input a
553
+ // `graph` hook adds moves a key, so the hooks run first, over every
554
+ // candidate, and the selection prunes what they left.
555
+ const hooksFirst = hasGraphHook && options.affected !== undefined
524
556
  // `--affected` keeps a bare request only when the diff reaches its
525
557
  // closure; a `pkg#task` the user named runs as it always does.
526
558
  if (options.affected !== undefined) {
559
+ const before = hooksFirst ? new Set(nodes.keys()) : null
560
+ if (hooksFirst) await graphStage()
527
561
  const named = new Set(tasks.filter((t) => t.includes('#')))
528
562
  const outright = new Set(options.selectedOutright)
529
563
  const ids = requested.map((r) => `${r.project}#${r.task}`)
@@ -532,7 +566,20 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
532
566
  (r, i) => named.has(ids[i]!) || outright.has(r.project) || reached.has(ids[i]!),
533
567
  )
534
568
  if (kept.length === 0) return emptyRun('none-affected')
535
- if (kept.length < requested.length) {
569
+ if (before !== null) {
570
+ const asked = new Set(ids)
571
+ // What a hook added or asked for is the plugin's choice, kept as it
572
+ // would be on a run without the diff.
573
+ const roots = [
574
+ ...kept.map((r) => `${r.project}#${r.task}`),
575
+ ...[...nodes.values()]
576
+ .filter((n) => !before.has(n.id) || (n.requested && !asked.has(n.id)))
577
+ .map((n) => n.id),
578
+ ]
579
+ pruneToClosure(nodes, new Set(roots))
580
+ for (let i = unproven.length - 1; i >= 0; i--)
581
+ if (!nodes.has(unproven[i]![0])) unproven.splice(i, 1)
582
+ } else if (kept.length < requested.length) {
536
583
  unproven.length = 0
537
584
  nodes = graphOf(kept)
538
585
  }
@@ -605,33 +652,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
605
652
  localCache,
606
653
  )
607
654
  mark('git enumeration')
608
- if (hasHook(plugins, 'graph')) {
609
- // A hook edits each node's config in place, as `project` does, so the
610
- // edit is held to what the loader accepts from a user: a misspelled
611
- // `exec` field ran as an empty command and failed with no reason.
612
- const configPaths = new Map(projectMetas.map((m) => [m.name, m.configPath]))
613
- await applyGraphHooks(
614
- plugins,
615
- nodes,
616
- {
617
- workspaceRoot,
618
- cacheDir,
619
- warn: (m) => log.status(m),
620
- requested: [...nodes.values()].filter((n) => n.requested).map((n) => n.id),
621
- },
622
- (plugin) => {
623
- for (const n of nodes.values()) {
624
- if (isDefaultBuild(n.config)) continue
625
- const where = configPaths.get(n.projectName) ?? `${n.projectName} (no config file)`
626
- validateProjectConfig(
627
- { tasks: { [n.taskName]: n.config } },
628
- `${where} (after plugin '${plugin.name}')`,
629
- )
630
- }
631
- },
632
- workspaceConfig?.rules,
633
- )
634
- }
655
+ if (hasGraphHook && !hooksFirst) await graphStage()
635
656
  // Args after `--` go to requested commands only; with none among them
636
657
  // they reached nothing and the run passed, the `--watch` unheard.
637
658
  if ((options.forwardArgs?.length ?? 0) > 0) {
@@ -767,3 +788,23 @@ async function refuseUndeclaredDeps(
767
788
  if (!declared.has(name)) throw undeclaredDepsError(id, name)
768
789
  }
769
790
  }
791
+
792
+ /**
793
+ * Drops every node outside the `dependsOn` closure of `roots`, and demotes a
794
+ * requested one only a kept task pulls in: what a rebuild from the kept
795
+ * requests would hold, with the `graph` hooks' edits kept.
796
+ */
797
+ function pruneToClosure(nodes: Map<string, TaskNode>, roots: ReadonlySet<string>): void {
798
+ const keep = new Set<string>()
799
+ const stack = [...roots]
800
+ while (stack.length > 0) {
801
+ const id = stack.pop()!
802
+ if (keep.has(id)) continue
803
+ keep.add(id)
804
+ stack.push(...nodes.get(id)!.deps)
805
+ }
806
+ for (const [id, n] of nodes) {
807
+ if (!keep.has(id)) nodes.delete(id)
808
+ else if (!roots.has(id)) n.requested = false
809
+ }
810
+ }
@@ -663,6 +663,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
663
663
  `let cleanOutputs delete files outside it)`,
664
664
  )
665
665
  }
666
+ assertPosixGlob(g, `${where}.cache.outputs.files`, 'project')
666
667
  assertNoForeignToken(g, `${where}.cache.outputs.files`)
667
668
  if (namesDirItself(g)) {
668
669
  throw new UserError(
@@ -714,6 +715,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
714
715
  `use cache.inputs.workspaceFiles for workspace-root-relative inputs)`,
715
716
  )
716
717
  }
718
+ assertPosixGlob(g, `${where}.cache.inputs.files`, 'project')
717
719
  assertNoForeignToken(g, `${where}.cache.inputs.files`)
718
720
  if (namesDirItself(g)) {
719
721
  throw new UserError(
@@ -1196,6 +1198,39 @@ function isAbsoluteGlob(glob: string): boolean {
1196
1198
  return g.startsWith('/') || (g.includes('{') && /[{,]\//.test(g))
1197
1199
  }
1198
1200
 
1201
+ const DRIVE = /(^|[{,])[A-Za-z]:[\\/]+/g
1202
+
1203
+ /**
1204
+ * True when a backslash in the glob is a Windows separator. One before a
1205
+ * bracket, a brace, a `!` or a backslash is an escape (`app/\[id\]`,
1206
+ * `\{b\}`); one before anything else is a separator — `a\*b` reads as
1207
+ * `a/*b` on Windows far more often than as a file named with a star, which
1208
+ * `a?b` still selects.
1209
+ */
1210
+ function hasBackslashSeparator(body: string): boolean {
1211
+ for (let i = body.indexOf('\\'); i !== -1; i = body.indexOf('\\', i + 2)) {
1212
+ if (!'[]{}!\\'.includes(body[i + 1] ?? '/')) return true
1213
+ }
1214
+ return false
1215
+ }
1216
+
1217
+ /**
1218
+ * A Windows spelling (`src\**`, `C:/src/**`) loaded with only a "matched no
1219
+ * files" warning, and a task keyed on it never saw its source (X-67).
1220
+ */
1221
+ function assertPosixGlob(glob: string, where: string, base: string): void {
1222
+ const neg = glob.startsWith('!') ? '!' : ''
1223
+ const body = glob.slice(neg.length)
1224
+ DRIVE.lastIndex = 0
1225
+ const drive = DRIVE.test(body)
1226
+ if (!drive && !hasBackslashSeparator(body)) return
1227
+ const write = neg + body.replace(DRIVE, '$1').replace(/\\(?![[\]{}!\\])/g, '/')
1228
+ throw new UserError(
1229
+ `${where}: "${glob}" ${drive ? 'starts with a Windows drive' : 'uses a backslash as a path separator'} — ` +
1230
+ `task globs are ${base}-relative with forward slashes: write "${write}"`,
1231
+ )
1232
+ }
1233
+
1199
1234
  /**
1200
1235
  * `!!x` INVERTS the input set instead of double-negating it.
1201
1236
  *
@@ -1405,6 +1440,7 @@ function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): v
1405
1440
  `entries are workspace-root-relative and must stay within the workspace root`,
1406
1441
  )
1407
1442
  }
1443
+ assertPosixGlob(g, where, 'workspace-root')
1408
1444
  assertNoForeignToken(g, where)
1409
1445
  if (namesDirItself(g)) {
1410
1446
  throw new UserError(