@vzn/vx 0.0.18 → 0.0.21

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.
Files changed (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -34,6 +34,10 @@ export interface ParsedFilter {
34
34
  * to a concrete set of project names before calling applyFilters.
35
35
  */
36
36
  gitSince?: string
37
+ /** A path form carrying a glob (`./packages/*`): matched over the root-relative project dir. */
38
+ pathGlob?: Bun.Glob
39
+ /** The workspace root `pathGlob` is relative to. */
40
+ pathRoot?: string
37
41
  }
38
42
 
39
43
  export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
@@ -71,15 +75,35 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
71
75
 
72
76
  let isPath = false
73
77
  let matcher = s
74
- if (s.startsWith('./') || s === '.') {
78
+ let pathGlob: Bun.Glob | undefined
79
+ const pathForm =
80
+ s.startsWith('./') || s === '.'
81
+ ? s
82
+ : s.startsWith('{') && s.endsWith('}')
83
+ ? s.slice(1, -1)
84
+ : undefined
85
+ if (pathForm !== undefined) {
75
86
  isPath = true
76
- matcher = path.resolve(workspaceRoot, s)
77
- } else if (s.startsWith('{') && s.endsWith('}')) {
78
- isPath = true
79
- matcher = path.resolve(workspaceRoot, s.slice(1, -1))
87
+ matcher = path.resolve(workspaceRoot, pathForm)
88
+ // `./packages/*` — pnpm's and Turbo's spelling for "every package under
89
+ // packages": a glob over the root-relative project dir. Resolved
90
+ // against the workspace root like the literal form.
91
+ if (/[*?[\]{}]/.test(pathForm)) {
92
+ const rel = path.relative(workspaceRoot, matcher).split(path.sep).join('/')
93
+ pathGlob = new Bun.Glob(rel.replace(/\/+$/, ''))
94
+ }
80
95
  }
81
96
 
82
- return { raw, negate, withDeps, withDependents, onlyDeps, isPath, matcher }
97
+ return {
98
+ raw,
99
+ negate,
100
+ withDeps,
101
+ withDependents,
102
+ onlyDeps,
103
+ isPath,
104
+ matcher,
105
+ ...(pathGlob !== undefined ? { pathGlob, pathRoot: workspaceRoot } : {}),
106
+ }
83
107
  }
84
108
 
85
109
  function matchProjects(
@@ -95,6 +119,19 @@ function matchProjects(
95
119
  }
96
120
  const out: string[] = []
97
121
  if (filter.isPath) {
122
+ if (filter.pathGlob !== undefined) {
123
+ // The glob is matched against the project's own dir, as pnpm and
124
+ // Turbo do: `./packages/*` is the packages directly under `packages`,
125
+ // `./packages/**` reaches the nested ones too.
126
+ for (const p of projects) {
127
+ const rel = path
128
+ .relative(filter.pathRoot ?? '', p.dir)
129
+ .split(path.sep)
130
+ .join('/')
131
+ if (filter.pathGlob.match(rel)) out.push(p.name)
132
+ }
133
+ return out
134
+ }
98
135
  const prefix = filter.matcher + path.sep
99
136
  for (const p of projects) {
100
137
  if (p.dir === filter.matcher || p.dir.startsWith(prefix)) out.push(p.name)
@@ -44,13 +44,47 @@ export const WORKSPACE_FINGERPRINT_FILES = [
44
44
  * invalidates every cached entry. Coarse but correct.
45
45
  */
46
46
  export async function computeWorkspaceFingerprint(workspaceRoot: string): Promise<string> {
47
- let h = 0n
47
+ return (await computeWorkspaceFingerprints(workspaceRoot, NONE)).all
48
+ }
49
+
50
+ const NONE: ReadonlySet<string> = new Set()
51
+
52
+ export interface WorkspaceFingerprints {
53
+ /** Every file folded — what the config-evaluation cache keys on. */
54
+ readonly all: string
55
+ /**
56
+ * The same fold minus the files a plugin claims (`VxPlugin.fingerprint`)
57
+ * — what every task key folds. Identical to `all` with nothing claimed.
58
+ */
59
+ readonly unclaimed: string
60
+ }
61
+
62
+ /**
63
+ * Both digests from one read of each file. A claimed file leaves the key
64
+ * digest entirely (its name too): the claimant folds what it means per
65
+ * project, and a fold of "present" would still re-key the workspace when
66
+ * the file appeared.
67
+ */
68
+ export async function computeWorkspaceFingerprints(
69
+ workspaceRoot: string,
70
+ claimed: ReadonlySet<string>,
71
+ ): Promise<WorkspaceFingerprints> {
72
+ let all = 0n
73
+ let unclaimed = 0n
48
74
  for (const f of WORKSPACE_FINGERPRINT_FILES) {
49
75
  const full = path.join(workspaceRoot, f)
50
76
  const file = Bun.file(full)
51
77
  if (!(await file.exists())) continue
52
- h = xxh3(`${f}\0`, h)
53
- h = xxh3(await file.bytes(), h)
78
+ const bytes = await file.bytes()
79
+ all = xxh3(`${f}\0`, all)
80
+ all = xxh3(bytes, all)
81
+ if (claimed.has(f)) continue
82
+ unclaimed = xxh3(`${f}\0`, unclaimed)
83
+ unclaimed = xxh3(bytes, unclaimed)
54
84
  }
85
+ return { all: hex(all), unclaimed: hex(unclaimed) }
86
+ }
87
+
88
+ function hex(h: bigint): string {
55
89
  return h.toString(16).padStart(16, '0')
56
90
  }
@@ -1,9 +1,19 @@
1
1
  // Module contract. Cross-module imports must come through here; see
2
2
  // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
3
 
4
- export { affectedProjects, defaultAffectedBase, workspaceGlobsMatch } from './affected.js'
4
+ export {
5
+ affectedProjects,
6
+ defaultAffectedBase,
7
+ type FingerprintClaims,
8
+ workspaceGlobsMatch,
9
+ } from './affected.js'
5
10
  export { applyFilters, parseFilter } from './filter.js'
6
- export { computeWorkspaceFingerprint, WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
11
+ export {
12
+ computeWorkspaceFingerprint,
13
+ computeWorkspaceFingerprints,
14
+ WORKSPACE_FINGERPRINT_FILES,
15
+ type WorkspaceFingerprints,
16
+ } from './fingerprint.js'
7
17
  export { computeNestedProjectDirs } from './nested-dirs.js'
8
18
  export { buildPackageGraph, type PackageGraph } from './package-graph.js'
9
19
  export {
@@ -11,6 +21,7 @@ export {
11
21
  LOCKFILE_NAME,
12
22
  LOCKFILE_VERSION,
13
23
  lockfilePath,
24
+ FROZEN_WITHOUT_LOCK,
14
25
  readLockfile,
15
26
  writeLockfile,
16
27
  type Lockfile,
@@ -20,8 +31,9 @@ export {
20
31
  loadProjectConfig,
21
32
  loadProjectConfigs,
22
33
  loadWorkspaceConfig,
23
- validateProjectConfig,
34
+ WORKSPACE_CONFIG_FILENAMES,
24
35
  } from './project-loader.js'
36
+ export { validateProjectConfig } from './config-schema.js'
25
37
  export type { LoadProjectConfigOptions } from './project-loader.js'
26
38
  export {
27
39
  blobOidOf,
@@ -33,7 +45,22 @@ export {
33
45
  findWorkspaceRoot,
34
46
  listProjects,
35
47
  loadWorkspace,
48
+ memberBaseDirs,
36
49
  resolveCacheDir,
37
50
  type ProjectEntry,
38
51
  type ProjectMeta,
39
52
  } from './workspace.js'
53
+ // The migration seam (see migration.ts) and core's own mapper, the scripts one.
54
+ export {
55
+ applyMigration,
56
+ PERSISTENT_TASK_NAMES,
57
+ PERSISTENT_TODO,
58
+ quoteTsLiteral,
59
+ type ApplyMigrationArgs,
60
+ type MigrationFormat,
61
+ type GeneratedProject,
62
+ type GeneratedTask,
63
+ type MigrationPlan,
64
+ type RawExpr,
65
+ } from './migration.js'
66
+ export { delegatedScript, migrateScripts } from './migrate-scripts.js'
@@ -14,7 +14,7 @@
14
14
  import path from 'node:path'
15
15
  import type { ProjectConfig } from '../config.js'
16
16
  import { relPosix, UserError } from '../util/index.js'
17
- import { validateProjectConfig } from './project-loader.js'
17
+ import { validateProjectConfig } from './config-schema.js'
18
18
 
19
19
  export const LOCKFILE_NAME = 'vx-lock.json'
20
20
  export const LOCKFILE_VERSION = 1
@@ -43,6 +43,10 @@ export function lockfilePath(root: string): string {
43
43
  * user-editable file, so this is a system boundary: malformed content
44
44
  * throws a `UserError` instead of crashing deeper in the run.
45
45
  */
46
+ /** What a `--frozen` verb says when there is no lock to read. */
47
+ export const FROZEN_WITHOUT_LOCK =
48
+ "--frozen requires vx-lock.json at the workspace root — run 'vx lock' and commit it"
49
+
46
50
  export async function readLockfile(root: string): Promise<Lockfile | null> {
47
51
  const file = Bun.file(lockfilePath(root))
48
52
  if (!(await file.exists())) return null
@@ -96,12 +100,13 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
96
100
  }
97
101
 
98
102
  /**
99
- * Run-time config load from the lock: verify the config file's content
100
- * hash against the lock entry, then return the FROZEN resolved config.
101
- * No evaluation happens — this is the fast, eval-free trust path. A
102
- * changed file or an unlocked project is a hard error: the lock's
103
- * contract is "what runs is what was locked", and silently falling
104
- * back to evaluation would break frozen-env semantics.
103
+ * Run-time config load from the lock: return the FROZEN resolved config
104
+ * for a project the lock has an entry for. No evaluation happens — this
105
+ * is the fast, eval-free trust path — and no staleness check either (see
106
+ * below): the lock's contract is "what runs is what was locked", so a
107
+ * config file edited since `vx lock` still runs as locked until
108
+ * `vx lock --check` says otherwise. An unlocked project is a hard error;
109
+ * silently falling back to evaluation would break frozen-env semantics.
105
110
  */
106
111
  export async function frozenProjectConfig(
107
112
  lock: Lockfile,
@@ -3,15 +3,18 @@
3
3
  //
4
4
  // Scripts carry a command and nothing else, so the mapping is honest about
5
5
  // what it cannot know: every task gets `exec.command` verbatim; `build`
6
- // gets the conventional `dependsOn: ['^build']` and `test` / `lint` /
7
- // `typecheck` wait for `build` when the package has one; a dev-server
6
+ // gets the conventional `dependsOn: ['^build']` and `test` / `typecheck`
7
+ // wait for `build` when the package has one (`lint` does not); a dev-server
8
8
  // shaped script becomes persistent. Caching is opt-in and needs declared
9
- // inputs AND outputs, which a script cannot tell us — so `build` is
10
- // emitted with a cache block whose inputs are the whole project and whose
11
- // outputs are EMPTY, under a TODO naming what to fill in. A cache block
12
- // that guessed `dist/**` would restore the wrong tree for every package
13
- // that writes somewhere else, and a wrong restore is the worst failure
14
- // vx has.
9
+ // inputs AND outputs, which a script cannot tell us — so NO task gets a
10
+ // cache block; `build` carries a TODO showing the block to add. Until
11
+ // 2026-09-04 `build` was emitted with whole-project inputs and EMPTY
12
+ // outputs "to fill in": that is not an uncached task but a no-output one —
13
+ // it hits on unchanged inputs and skips the build with nothing to restore,
14
+ // so a deleted `dist` stayed deleted under a green `up-to-date` run
15
+ // (reproduced on the init walkthrough). A block that guessed `dist/**`
16
+ // would restore the wrong tree for every package that writes somewhere
17
+ // else, and a wrong restore is the worst failure vx has.
15
18
  //
16
19
  // Two npm conventions are mapped rather than copied, because copying them
17
20
  // loses behaviour: `pre<x>` / `post<x>` hooks, which npm runs around `x`
@@ -21,11 +24,18 @@
21
24
  // `<pm> run <other>` becomes a GROUP over `<other>`, so the graph sees the
22
25
  // dependency instead of a package-manager subprocess it cannot cache.
23
26
 
24
- import type { ProjectMeta } from '../workspace/index.js'
25
- import type { GeneratedProject, GeneratedTask, MigrationPlan } from './migrate.js'
27
+ import type { ProjectMeta } from './workspace.js'
28
+ import {
29
+ type GeneratedProject,
30
+ type GeneratedTask,
31
+ type MigrationPlan,
32
+ PERSISTENT_TASK_NAMES,
33
+ PERSISTENT_TODO,
34
+ } from './migration.js'
26
35
 
27
- const PERSISTENT = new Set(['dev', 'start', 'serve', 'watch', 'preview'])
28
- const AFTER_BUILD = new Set(['test', 'lint', 'typecheck', 'check', 'e2e'])
36
+ // `lint` is not here: a linter reads sources, and an edge to `build`
37
+ // serialises the two for nothing (the init walkthrough, 2026-09-04).
38
+ const AFTER_BUILD = new Set(['test', 'typecheck', 'check', 'e2e'])
29
39
  const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepublishOnly|install)$/
30
40
 
31
41
  /**
@@ -97,17 +107,14 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
97
107
  )
98
108
  }
99
109
  const task: Record<string, unknown> = { exec: { command } }
100
- if (PERSISTENT.has(name)) {
110
+ if (PERSISTENT_TASK_NAMES.has(name)) {
101
111
  task['exec'] = { command, persistent: {} }
102
- todos.push(
103
- 'persistent: add `readyWhen: "<line the server prints when ready>"` so dependents wait for it',
104
- )
112
+ todos.push(PERSISTENT_TODO)
105
113
  }
106
114
  if (name === 'build') {
107
115
  task['dependsOn'] = ['^build']
108
- task['cache'] = { inputs: { files: ['**/*'] }, outputs: { files: [] } }
109
116
  todos.push(
110
- "cache: inputs default to the whole project ('**/*'); narrow them (e.g. 'src/**') and declare outputs (e.g. 'dist/**') — nothing is cached until outputs are declared",
117
+ "cache: add `cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }` with this package's real inputs and outputs — without it the task always runs; a block with EMPTY outputs would be a cached no-op, not an uncached task",
111
118
  )
112
119
  } else if (AFTER_BUILD.has(name) && hasBuild) {
113
120
  task['dependsOn'] = ['build']
@@ -118,7 +125,7 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
118
125
  }
119
126
  return {
120
127
  headerNotes: [
121
- 'each script became a task with its command verbatim; caching needs declared inputs and outputs, so only `build` got a cache block, with empty outputs to fill in',
128
+ 'each script became a task with its command verbatim; caching needs declared inputs and outputs, so no task got a cache block — `build` carries a TODO showing the one to add',
122
129
  ],
123
130
  projects,
124
131
  extraFiles: [],