@vzn/vx 0.0.12 → 0.0.15

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.
@@ -1,7 +1,13 @@
1
1
  import type { TaskConfig } from '../config.js'
2
2
  import { UserError } from '../util/index.js'
3
3
  import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
4
- import { DependencySpecError, parseDependencySpec, type DependencySpec } from './dependency-spec.js'
4
+ import {
5
+ DependencySpecError,
6
+ compileTaskPattern,
7
+ isTaskPattern,
8
+ parseDependencySpec,
9
+ type DependencySpec,
10
+ } from './dependency-spec.js'
5
11
 
6
12
  // Re-exported so existing importers keep working while the type's home
7
13
  // moves to workspace (it's the joint product of discovery + loading).
@@ -204,28 +210,50 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
204
210
  }
205
211
 
206
212
  // dependsOn is about which tasks to ADD to the graph, not which
207
- // to filter. Wildcards and negation aren't meaningful here —
208
- // they're cache.inputs.tasks operations.
213
+ // to filter. BARE wildcards ("*"/"^*" = "everything upstream") and
214
+ // negation aren't meaningful here — they're cache.inputs.tasks
215
+ // operations. PARTIAL patterns (`build.*`, `^build.*`) are legal:
216
+ // they name a namespace of tasks to add (Nx 19.5 parity).
209
217
  if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
210
- throw new UserError(`Task ${id}: dependsOn does not accept wildcards (got "${raw}")`)
218
+ throw new UserError(`Task ${id}: dependsOn does not accept bare wildcards (got "${raw}")`)
211
219
  }
212
220
  if (spec.negated) {
213
221
  throw new UserError(`Task ${id}: dependsOn does not accept negation (got "${raw}")`)
214
222
  }
223
+ if (spec.kind === 'cross' && (isTaskPattern(spec.task) || isTaskPattern(spec.project))) {
224
+ throw new UserError(
225
+ `Task ${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
226
+ )
227
+ }
215
228
  // CLI `--excludeDependencies=name1,name2` drops edges whose target
216
229
  // task name matches, regardless of bucket (self / deps / cross).
230
+ // Pattern specs re-apply the filter per EXPANDED name below.
217
231
  if (skipNames?.has(spec.task)) continue
218
232
 
219
233
  if (spec.kind === 'self') {
220
- // Missing target is a hard error — the user typed a name that
221
- // doesn't resolve in this project.
222
- const child = addNode(projectName, spec.task, false)
223
- if (!child) {
224
- throw new UserError(
225
- `Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
226
- )
234
+ if (isTaskPattern(spec.task)) {
235
+ // `build.*` — every OTHER same-project task matching the
236
+ // pattern (the declaring task never matches itself — that
237
+ // would be an instant self-cycle). Zero matches is legal: a
238
+ // preset-spread pattern needn't match in every project.
239
+ const re = compileTaskPattern(spec.task)
240
+ for (const name of Object.keys(project.config.tasks ?? {})) {
241
+ if (name === taskName || !re.test(name)) continue
242
+ if (skipNames?.has(name)) continue
243
+ const child = addNode(projectName, name, false)
244
+ if (child) node.deps.push(child.id)
245
+ }
246
+ } else {
247
+ // Missing target is a hard error — the user typed a name that
248
+ // doesn't resolve in this project.
249
+ const child = addNode(projectName, spec.task, false)
250
+ if (!child) {
251
+ throw new UserError(
252
+ `Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
253
+ )
254
+ }
255
+ node.deps.push(child.id)
227
256
  }
228
- node.deps.push(child.id)
229
257
  } else if (spec.kind === 'deps') {
230
258
  // Nearest-holder frontier (Turbo/Nx direct-deps parity +
231
259
  // sparse bridging): walk the package dep graph from this
@@ -235,15 +263,36 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
235
263
  // through so a sparse dep doesn't break ordering to deeper
236
264
  // holders. The visited set both dedupes shared subtrees and
237
265
  // terminates on package-graph cycles (legal in PMs).
266
+ //
267
+ // With a pattern (`^build.*`), a holder is a package declaring
268
+ // AT LEAST ONE matching task and it receives edges to ALL its
269
+ // matches — holder-ness is about declaration, so a holder still
270
+ // stops the walk even when every match is --excludeDependencies'd.
271
+ const re = isTaskPattern(spec.task) ? compileTaskPattern(spec.task) : null
238
272
  const visited = new Set<string>()
239
273
  const frontier = [...packageGraph.directDeps(projectName)]
240
274
  while (frontier.length > 0) {
241
275
  const target = frontier.pop()!
242
276
  if (visited.has(target)) continue
243
277
  visited.add(target)
244
- const child = addNode(target, spec.task, false)
245
- if (child) node.deps.push(child.id)
246
- else frontier.push(...packageGraph.directDeps(target))
278
+ if (re === null) {
279
+ const child = addNode(target, spec.task, false)
280
+ if (child) node.deps.push(child.id)
281
+ else frontier.push(...packageGraph.directDeps(target))
282
+ } else {
283
+ const names = Object.keys(projects.get(target)?.config.tasks ?? {}).filter((n) =>
284
+ re.test(n),
285
+ )
286
+ if (names.length > 0) {
287
+ for (const name of names) {
288
+ if (skipNames?.has(name)) continue
289
+ const child = addNode(target, name, false)
290
+ if (child) node.deps.push(child.id)
291
+ }
292
+ } else {
293
+ frontier.push(...packageGraph.directDeps(target))
294
+ }
295
+ }
247
296
  }
248
297
  } else {
249
298
  // Cross-project edge: pkg#task. Missing target is a hard error
@@ -258,8 +307,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
258
307
  }
259
308
  }
260
309
 
261
- // Stable ordering for deterministic scheduling and cache keys.
262
- node.deps.sort()
310
+ // Stable ordering for deterministic scheduling and cache keys — deduped:
311
+ // a target named twice (an exact entry + a pattern matching it, or a
312
+ // literal duplicate) must contribute ONE edge, not a double-folded
313
+ // upstream hash and a doubled DOT edge.
314
+ node.deps = [...new Set(node.deps)].sort()
263
315
  return node
264
316
  }
265
317
 
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ export type {
21
21
  CacheConfig,
22
22
  CacheInputs,
23
23
  CacheOutputs,
24
+ ResourcesConfig,
24
25
  SandboxConfig,
25
26
  SandboxNetworkConfig,
26
27
  } from './config.js'
@@ -52,7 +53,16 @@ export type {
52
53
  } from './orchestrator/index.js'
53
54
  export { defaultLogger, resolveOutputView } from './orchestrator/index.js'
54
55
  export { buildTaskGraph, expandRequested, isGroupTask, markSurfacedDeps } from './graph/index.js'
55
- export type { TaskNode, TaskOutcome, TaskStatus, VerifyVerdict } from './graph/index.js'
56
+ export type {
57
+ OutputFingerprint,
58
+ TaskNode,
59
+ TaskOutcome,
60
+ TaskStatus,
61
+ VerifyVerdict,
62
+ } from './graph/index.js'
63
+ // The one output-tree diff implementation (verify verdicts + a serve's
64
+ // cross-machine fingerprint diff both name rels through it).
65
+ export { diffOutputTrees } from './orchestrator/index.js'
56
66
 
57
67
  // Cache classes + the layer interface (the `cache` capability's currency)
58
68
  // and input-output resolution. The blob-CAS/digest substrate
@@ -63,16 +73,29 @@ export type { TaskNode, TaskOutcome, TaskStatus, VerifyVerdict } from './graph/i
63
73
  export {
64
74
  Cache,
65
75
  LayeredCache,
66
- RemoteCache,
67
76
  GitFilesCache,
68
77
  cleanOutputs,
69
78
  resolveInputs,
70
79
  resolveOutputs,
71
80
  } from './cache/index.js'
72
- export type { CacheLayer, RunRecord, InvocationRecord } from './cache/index.js'
81
+ export type { CacheLayer, RemoteCacheLayer, RunRecord, InvocationRecord } from './cache/index.js'
73
82
 
74
- // Workspace discovery — an out-of-process service/CLI needs these.
83
+ // Workspace discovery + the project/config catalog surface — an
84
+ // out-of-process service/CLI needs these. `readLockfile` is THE one reader
85
+ // of vx-lock.json (the format carries its own version sentinel; a second
86
+ // parser in a sibling package would drift), and the loader chain
87
+ // (`loadWorkspace` → `listProjectMetas` → `loadProjectConfig`) is the same
88
+ // one `vx show` uses. Workspace's `listProjects` re-exports as
89
+ // `listProjectMetas` — the bare name belongs to the metrics query below.
75
90
  export { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from './workspace/index.js'
91
+ export { readLockfile, LOCKFILE_NAME } from './workspace/index.js'
92
+ export type { Lockfile, LockfileEntry } from './workspace/index.js'
93
+ export {
94
+ loadWorkspace,
95
+ loadProjectConfig,
96
+ listProjects as listProjectMetas,
97
+ } from './workspace/index.js'
98
+ export type { ProjectMeta } from './workspace/index.js'
76
99
 
77
100
  // Plugin API — the run-level extension points. Behavior capabilities
78
101
  // (backend / cache) change WHAT/HOW work runs; the observe-only `telemetry`
@@ -181,8 +204,10 @@ export {
181
204
  getHitRateSplit,
182
205
  getInvocation,
183
206
  getParallelismHistory,
207
+ getPeriodComparison,
184
208
  getPrunableEntries,
185
209
  getRecentFailures,
210
+ getRegressions,
186
211
  getRun,
187
212
  getRunHeatmap,
188
213
  getRunTrends,
@@ -26,6 +26,7 @@ import { isGroupTask, type TaskNode, type TaskOutcome, type VerifyVerdict } from
26
26
  import {
27
27
  classifyDeterminism,
28
28
  diffOutputTrees,
29
+ foldFingerprint,
29
30
  hashOutputTree,
30
31
  outputRefs,
31
32
  undeclaredInputPaths,
@@ -83,13 +84,17 @@ export interface ExecuteArgs {
83
84
  * save and its outputs are content-compared; a divergence flags the task
84
85
  * non-hermetic. When `inputs` is set, an executed + cacheable task is
85
86
  * forced through the declared-input baseline sandbox and any read outside
86
- * those inputs flags the declared `cache.inputs` as incomplete. `allow`
87
- * exempts known-nondeterministic task ids from failing the run. Pure
88
- * side-channel — the re-run never saves, nothing is hashed. Undefined = off.
87
+ * those inputs flags the declared `cache.inputs` as incomplete. When
88
+ * `fingerprint` is set, the executed task's output tree is fingerprinted
89
+ * and attached to the outcome (no re-run — the cross-machine diff feed).
90
+ * `allow` exempts known-nondeterministic task ids from failing the run.
91
+ * Pure side-channel — the re-run never saves, nothing is hashed.
92
+ * Undefined = off.
89
93
  */
90
94
  verify?: {
91
95
  determinism: boolean
92
96
  inputs: boolean
97
+ fingerprint: boolean
93
98
  allow: ReadonlySet<string>
94
99
  }
95
100
  /** Per-run memo for `git ls-files` (one entry per project dir). */
@@ -537,12 +542,23 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
537
542
  stdout: result.stdout,
538
543
  },
539
544
  })
540
- // --verify: fingerprint attempt 1's outputs by CONTENT (mtime-independent,
541
- // reusing the input-hashing OID primitive) so the determinism re-run below
542
- // can prove the cached bytes are a pure function of the declared inputs.
543
- if (args.verify?.determinism && outputFiles.length + wsOutputFiles.length > 0) {
545
+ // --verify: fingerprint attempt 1's outputs by CONTENT (raw bytes, never
546
+ // the mtime+size memo). The determinism re-run below compares against it,
547
+ // and the fingerprinting modes (`--verify`/`=all`/`=fingerprint`) fold it
548
+ // into the shipped `outputFp` — the cross-machine diff feed.
549
+ //
550
+ // Gate on DECLARED outputs (`outputs`/`wsOutputs` globs), not the
551
+ // RESOLVED file count: a task that declares `dist/**` but produces zero
552
+ // files this run must still ship the EMPTY tree, or the cross-machine
553
+ // diff can't pair it against a platform where the same glob DID match —
554
+ // a platform-conditional glob producing nothing on one OS is exactly
555
+ // the divergence Phase 4 exists to catch. The empty fold is well-defined
556
+ // (a fixed sentinel tree), so two both-empty platforms stay silent.
557
+ if (
558
+ (args.verify?.fingerprint || args.verify?.determinism) &&
559
+ outputs.length + wsOutputs.length > 0
560
+ ) {
544
561
  verifyFp1 = await hashOutputTree(
545
- cache,
546
562
  outputRefs(node.projectDir, outputFiles, args.workspaceRoot, wsOutputFiles),
547
563
  )
548
564
  }
@@ -618,7 +634,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
618
634
  willWrite &&
619
635
  !result.timedOut
620
636
  ) {
621
- if (verifyFp1 === undefined) {
637
+ // Determinism verdict is keyed on RESOLVED outputs (unchanged): a task
638
+ // that declares outputs but produced none this run has nothing to
639
+ // content-compare, so it stays `no-outputs` with no re-run — even though
640
+ // `verifyFp1` is now the (empty) declared-outputs fingerprint that
641
+ // `--verify=fingerprint` ships for the cross-machine diff. `verifyFp1`
642
+ // is keyed by resolved output file, so an empty map means "declared but
643
+ // produced nothing" — the in-scope stand-in for the resolved-zero count.
644
+ if (verifyFp1 === undefined || verifyFp1.size === 0) {
622
645
  verify = { kind: 'no-outputs' } // cacheable but nothing to replay (e.g. lint)
623
646
  } else {
624
647
  const rerun = await runAttempt()
@@ -626,7 +649,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
626
649
  verify = { kind: 'rerun-failed', exitCode: rerun.exitCode }
627
650
  } else {
628
651
  const fp2 = await hashOutputTree(
629
- cache,
630
652
  outputRefs(
631
653
  node.projectDir,
632
654
  await resolveOutputs({
@@ -646,14 +668,47 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
646
668
  args.verify.allow.has(node.id),
647
669
  )
648
670
  }
671
+ // Put attempt 1's saved bytes back so disk == the cached artifact
672
+ // REGARDLESS of the verdict. Mirror the restoreHit sequence exactly:
673
+ // clean the declared globs FIRST (a nondeterministic re-run may have
674
+ // written a stray filename the restore would never overwrite), then
675
+ // restore, then record the exact changed paths so a downstream
676
+ // same-project task's git snapshot can't go stale. fp1's keys ARE
677
+ // attempt 1's saved rels (bare project rels + workspace-outputs/-
678
+ // prefixed ws rels — the artifact namespace).
679
+ let cleanedRels: string[] = []
680
+ let cleanedWsRels: string[] = []
681
+ if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
682
+ if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
649
683
  await cache.restoreOutputs(
650
684
  hash,
651
685
  node.projectDir,
652
686
  wsOutputs.length > 0 ? args.workspaceRoot : undefined,
653
687
  )
688
+ const savedKeys = [...verifyFp1.keys()]
689
+ if (outputs.length > 0) {
690
+ args.gitFilesCache?.markOutputsChanged(node.projectDir, [
691
+ ...cleanedRels,
692
+ ...savedKeys.filter((k) => !k.startsWith(WORKSPACE_OUTPUT_PREFIX)),
693
+ ])
694
+ }
695
+ if (wsOutputs.length > 0) {
696
+ args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
697
+ ...cleanedWsRels,
698
+ ...savedKeys
699
+ .filter((k) => k.startsWith(WORKSPACE_OUTPUT_PREFIX))
700
+ .map((k) => k.slice(WORKSPACE_OUTPUT_PREFIX.length)),
701
+ ])
702
+ }
654
703
  }
655
704
  }
656
705
 
706
+ // Cross-machine diff feed: ship attempt 1's fingerprint (the SAVED bytes —
707
+ // the determinism re-run above never clobbers verifyFp1) so a connected
708
+ // serve can pair it with other platforms' reports for the same cache key.
709
+ const outputFp =
710
+ args.verify?.fingerprint && verifyFp1 !== undefined ? foldFingerprint(verifyFp1) : undefined
711
+
657
712
  return {
658
713
  node,
659
714
  status: effectiveExitCode === 0 ? 'success' : 'failed',
@@ -666,6 +721,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
666
721
  wallclockStartNs,
667
722
  wallclockEndNs,
668
723
  ...(verify !== undefined ? { verify } : {}),
724
+ ...(outputFp !== undefined ? { outputFp } : {}),
669
725
  ...(finalViolations.length > 0
670
726
  ? {
671
727
  sandboxViolations: finalViolations.length,
@@ -95,6 +95,9 @@ export type {
95
95
  TelemetrySink,
96
96
  TelemetrySource,
97
97
  } from './telemetry.js'
98
+ // `diffOutputTrees` is the ONE tree-diff implementation — the verify verdict
99
+ // and a serve's cross-machine fingerprint diff must never drift on it.
100
+ export { diffOutputTrees } from './verify.js'
98
101
  export { deriveStableKeys } from './stable-keys.js'
99
102
  export type { DeriveStableKeysArgs, StableKey } from './stable-keys.js'
100
103
  export { captureGitContext, captureWorkspaceIdentity, resolveCacheScope } from './run-context.js'
@@ -112,8 +115,10 @@ export {
112
115
  getHitRateSplit,
113
116
  getInvocation,
114
117
  getParallelismHistory,
118
+ getPeriodComparison,
115
119
  getPrunableEntries,
116
120
  getRecentFailures,
121
+ getRegressions,
117
122
  getRun,
118
123
  getRunHeatmap,
119
124
  getRunTrends,
@@ -149,8 +154,14 @@ export type {
149
154
  ListInvocationsArgs,
150
155
  ListRunsArgs,
151
156
  ParallelismPoint,
157
+ PeriodComparison,
158
+ PeriodComparisonArgs,
159
+ PeriodStats,
152
160
  PrunableEntry,
153
161
  ProjectRollup,
162
+ RegressedTask,
163
+ RegressionArgs,
164
+ TaskMover,
154
165
  RunDetail,
155
166
  RunSummaryRow,
156
167
  StoragePoint,