@vzn/vx 0.0.16 → 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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -1,6 +1,6 @@
1
- // Filter DSL — pnpm-style selectors for `-F / --filter`.
1
+ // Filter DSL — pnpm-style selectors for `--filter`.
2
2
  //
3
- // <pattern> name glob (e.g. foo, @scope/*)
3
+ // <pattern> name glob, `*` = any characters (e.g. foo, @scope/*)
4
4
  // ./<dir> packages whose dir is at or under <dir> (relative to workspace root)
5
5
  // {<dir>} same as ./<dir>
6
6
  // <pattern>... pattern + its transitive workspace dependencies
@@ -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)
@@ -107,13 +144,24 @@ function matchProjects(
107
144
  }
108
145
  return out
109
146
  }
110
- const glob = new Bun.Glob(filter.matcher)
147
+ const re = compileNameGlob(filter.matcher)
111
148
  for (const p of projects) {
112
- if (glob.match(p.name)) out.push(p.name)
149
+ if (re.test(p.name)) out.push(p.name)
113
150
  }
114
151
  return out
115
152
  }
116
153
 
154
+ /**
155
+ * Compile a name pattern where `*` is the sole metacharacter and means "any
156
+ * characters" — pnpm's rule. A path glob would treat `/` as a separator, so
157
+ * `*` could never cross the `@scope/` boundary: `--filter '*'` would select
158
+ * only UNSCOPED packages, and `*core*` would match nothing at all.
159
+ */
160
+ function compileNameGlob(pattern: string): RegExp {
161
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
162
+ return new RegExp(`^${escaped}$`)
163
+ }
164
+
117
165
  export interface ApplyFiltersOptions {
118
166
  filters: ParsedFilter[]
119
167
  projects: ProjectMeta[]
@@ -125,6 +173,12 @@ export interface ApplyFiltersOptions {
125
173
  * stays pure + sync.
126
174
  */
127
175
  affectedByFilter?: Map<ParsedFilter, Set<string>>
176
+ /**
177
+ * Called once per filter that matched zero projects, before expansion.
178
+ * Only the TOTAL empty selection is an error, so without this a typo among
179
+ * several filters silently under-selects.
180
+ */
181
+ onNoMatch?: (filter: ParsedFilter) => void
128
182
  }
129
183
 
130
184
  export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
@@ -134,6 +188,7 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
134
188
 
135
189
  for (const f of opts.filters) {
136
190
  const matched = matchProjects(f, opts.projects, opts.affectedByFilter)
191
+ if (matched.length === 0) opts.onNoMatch?.(f)
137
192
  const expanded = new Set<string>()
138
193
  for (const name of matched) {
139
194
  if (!f.onlyDeps) expanded.add(name)
@@ -8,7 +8,11 @@ import { xxh3 } from '../util/index.js'
8
8
  // for projects that have that file present, which is fine: that's the
9
9
  // whole point of bumping CACHE_VERSION when the fingerprint surface
10
10
  // expands.
11
- const WORKSPACE_FINGERPRINT_FILES = [
11
+ // Exported so `--affected` can consult the SAME list. A change to any of
12
+ // these re-keys every task in the workspace, so selection has to widen to
13
+ // match — two copies of this list would silently drift into a run that
14
+ // rebuilds everything while selecting nothing.
15
+ export const WORKSPACE_FINGERPRINT_FILES = [
12
16
  // Lockfiles
13
17
  'pnpm-lock.yaml',
14
18
  'package-lock.json',
@@ -21,6 +25,17 @@ const WORKSPACE_FINGERPRINT_FILES = [
21
25
  'pnpm-workspace.yaml',
22
26
  ]
23
27
 
28
+ // DELIBERATELY ABSENT: `vx.workspace.{ts,mts,js,mjs}`. Everything it can
29
+ // declare — `concurrency`, `cacheDir`, `timeout`, and the
30
+ // plugin list — is placement, storage or observability, never what a
31
+ // command produces; core's three seams may change WHERE a task runs, not
32
+ // WHAT it runs (architecture principle #3). Folding it in would also do
33
+ // active harm: a laptop declaring only the local plugins and a CI runner
34
+ // declaring `reapi()` would compute different fingerprints and share not
35
+ // one cache entry — the same split the rejected `NODE_OPTIONS` non-goal
36
+ // describes. A per-task input that genuinely varies belongs in
37
+ // `cache.inputs`, which is the surface built for it.
38
+
24
39
  /**
25
40
  * One hash for the workspace as a whole, derived from whichever
26
41
  * lockfile + workspace-definition files exist at the root. Folded
@@ -29,13 +44,47 @@ const WORKSPACE_FINGERPRINT_FILES = [
29
44
  * invalidates every cached entry. Coarse but correct.
30
45
  */
31
46
  export async function computeWorkspaceFingerprint(workspaceRoot: string): Promise<string> {
32
- 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
33
74
  for (const f of WORKSPACE_FINGERPRINT_FILES) {
34
75
  const full = path.join(workspaceRoot, f)
35
76
  const file = Bun.file(full)
36
77
  if (!(await file.exists())) continue
37
- h = xxh3(`${f}\0`, h)
38
- 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)
39
84
  }
85
+ return { all: hex(all), unclaimed: hex(unclaimed) }
86
+ }
87
+
88
+ function hex(h: bigint): string {
40
89
  return h.toString(16).padStart(16, '0')
41
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 } 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 } 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,17 +21,46 @@ 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,
17
28
  type LockfileEntry,
18
29
  } from './lockfile.js'
19
- export { loadProjectConfig, loadWorkspaceConfig } from './project-loader.js'
30
+ export {
31
+ loadProjectConfig,
32
+ loadProjectConfigs,
33
+ loadWorkspaceConfig,
34
+ WORKSPACE_CONFIG_FILENAMES,
35
+ } from './project-loader.js'
36
+ export { validateProjectConfig } from './config-schema.js'
37
+ export type { LoadProjectConfigOptions } from './project-loader.js'
38
+ export {
39
+ blobOidOf,
40
+ configEvalKey,
41
+ configEvalKeyFromClosure,
42
+ type ConfigEvalStore,
43
+ } from './config-cache.js'
20
44
  export {
21
45
  findWorkspaceRoot,
22
46
  listProjects,
23
47
  loadWorkspace,
48
+ memberBaseDirs,
24
49
  resolveCacheDir,
25
50
  type ProjectEntry,
26
51
  type ProjectMeta,
27
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
@@ -52,7 +56,14 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
52
56
  } catch {
53
57
  throw new UserError(`${LOCKFILE_NAME} is not valid JSON — re-run \`vx lock\` or delete it`)
54
58
  }
55
- if (typeof parsed !== 'object' || parsed === null) {
59
+ // `Array.isArray` is not redundant with the `typeof` check: `typeof [] ===
60
+ // 'object'` and `[] !== null`, so an array sails through and the NEXT check
61
+ // reports `unsupported version undefined` — a version diagnostic for what is
62
+ // actually a shape problem, sending the reader to re-run `vx lock` for a file
63
+ // that was never the right shape. `validateProjectConfig` one layer down
64
+ // already guards this explicitly, which is what makes the omission here an
65
+ // inconsistency rather than an oversight.
66
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
56
67
  throw new UserError(`${LOCKFILE_NAME} must be a JSON object — re-run \`vx lock\``)
57
68
  }
58
69
  const lock = parsed as { version?: unknown; projects?: unknown }
@@ -62,7 +73,11 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
62
73
  `(this vx expects ${LOCKFILE_VERSION}) — re-run \`vx lock\``,
63
74
  )
64
75
  }
65
- if (typeof lock.projects !== 'object' || lock.projects === null) {
76
+ // Same trap, and this one is worse than a bad message: an ARRAY of entries is
77
+ // ACCEPTED, because `Object.entries` happily walks it — yielding a project
78
+ // literally named "0". `--frozen` then looks up real project names against a
79
+ // lock that declares none of them.
80
+ if (typeof lock.projects !== 'object' || lock.projects === null || Array.isArray(lock.projects)) {
66
81
  throw new UserError(`${LOCKFILE_NAME}: \`projects\` must be an object — re-run \`vx lock\``)
67
82
  }
68
83
  for (const [name, entry] of Object.entries(lock.projects)) {
@@ -85,12 +100,13 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
85
100
  }
86
101
 
87
102
  /**
88
- * Run-time config load from the lock: verify the config file's content
89
- * hash against the lock entry, then return the FROZEN resolved config.
90
- * No evaluation happens — this is the fast, eval-free trust path. A
91
- * changed file or an unlocked project is a hard error: the lock's
92
- * contract is "what runs is what was locked", and silently falling
93
- * 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.
94
110
  */
95
111
  export async function frozenProjectConfig(
96
112
  lock: Lockfile,
@@ -0,0 +1,134 @@
1
+ // package.json scripts → vx tasks: the source for a workspace that comes
2
+ // from nowhere (no turbo.json, no nx). `vx init` is this mapper.
3
+ //
4
+ // Scripts carry a command and nothing else, so the mapping is honest about
5
+ // what it cannot know: every task gets `exec.command` verbatim; `build`
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
+ // shaped script becomes persistent. Caching is opt-in and needs declared
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.
18
+ //
19
+ // Two npm conventions are mapped rather than copied, because copying them
20
+ // loses behaviour: `pre<x>` / `post<x>` hooks, which npm runs around `x`
21
+ // without being named, are folded into `x`'s command in that order (a
22
+ // standalone `prebuild` task is one `vx run build` never runs — and the
23
+ // hook is usually `rimraf dist`); and a script that is nothing but
24
+ // `<pm> run <other>` becomes a GROUP over `<other>`, so the graph sees the
25
+ // dependency instead of a package-manager subprocess it cannot cache.
26
+
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'
35
+
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'])
39
+ const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepublishOnly|install)$/
40
+
41
+ /**
42
+ * The script a command delegates to when it is NOTHING but a package-manager
43
+ * invocation of another script: `npm run x`, `pnpm x`, `yarn run x`, `bun run
44
+ * x`, plus npm's bare `npm test` / `npm start`. Flags, arguments or a chain
45
+ * make it a real command again, which is left verbatim.
46
+ */
47
+ export function delegatedScript(command: string): string | null {
48
+ const m =
49
+ /^(?:(?:npm run|pnpm(?: run)?|yarn(?: run)?|bun(?: run)?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
50
+ command.trim(),
51
+ )
52
+ return m === null ? null : (m[1] ?? m[2])!
53
+ }
54
+
55
+ function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
56
+ // package.json is a boundary: `scripts` is whatever the file holds. A
57
+ // string or an array would enumerate its indices as script names.
58
+ const raw = (meta.packageJson as unknown as { scripts?: unknown }).scripts
59
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}
60
+ return raw as Record<string, unknown>
61
+ }
62
+
63
+ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
64
+ const projects: GeneratedProject[] = []
65
+ for (const meta of metas) {
66
+ const scripts = scriptsOf(meta)
67
+ const names = Object.keys(scripts).filter(
68
+ (n) => typeof scripts[n] === 'string' && scripts[n] !== '',
69
+ )
70
+ if (names.length === 0) continue
71
+ const hasBuild = names.includes('build')
72
+ const has = (n: string): boolean => names.includes(n)
73
+ const tasks: GeneratedTask[] = []
74
+ for (const name of names) {
75
+ // Lifecycle hooks are npm's, not tasks anyone runs by name.
76
+ if (LIFECYCLE.test(name)) continue
77
+ // A hook of a script that exists rides inside that script's command.
78
+ const hookOf = /^(pre|post)(.+)$/.exec(name)
79
+ if (hookOf !== null && has(hookOf[2]!) && !LIFECYCLE.test(hookOf[2]!)) continue
80
+
81
+ const todos: string[] = []
82
+ const own = scripts[name] as string
83
+ const delegate = delegatedScript(own)
84
+ // npm lifecycle hooks (`prepack`, `prepublishOnly`, …) belong to the
85
+ // package manager and never ride inside a task — `pack` stays alone.
86
+ const hook = (h: string): boolean => has(h) && !LIFECYCLE.test(h)
87
+ const hooks = [`pre${name}`, `post${name}`].filter(hook)
88
+ if (delegate !== null && has(delegate) && delegate !== name && hooks.length === 0) {
89
+ // A group: no exec, the graph runs the target. `npm test` calling
90
+ // `vitest` through `npm run test:unit` is two tasks, not a subprocess.
91
+ const task: Record<string, unknown> = { dependsOn: [delegate] }
92
+ if (AFTER_BUILD.has(name) && hasBuild && delegate !== 'build') {
93
+ task['dependsOn'] = [delegate, 'build']
94
+ }
95
+ tasks.push({ name, todos, task })
96
+ continue
97
+ }
98
+
99
+ const command = [
100
+ ...(hook(`pre${name}`) ? [scripts[`pre${name}`] as string] : []),
101
+ own,
102
+ ...(hook(`post${name}`) ? [scripts[`post${name}`] as string] : []),
103
+ ].join(' && ')
104
+ if (hooks.length > 0) {
105
+ todos.push(
106
+ `npm ran ${hooks.map((h) => `\`${h}\``).join(' and ')} around this script without being asked; folded into the command in that order`,
107
+ )
108
+ }
109
+ const task: Record<string, unknown> = { exec: { command } }
110
+ if (PERSISTENT_TASK_NAMES.has(name)) {
111
+ task['exec'] = { command, persistent: {} }
112
+ todos.push(PERSISTENT_TODO)
113
+ }
114
+ if (name === 'build') {
115
+ task['dependsOn'] = ['^build']
116
+ todos.push(
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",
118
+ )
119
+ } else if (AFTER_BUILD.has(name) && hasBuild) {
120
+ task['dependsOn'] = ['build']
121
+ }
122
+ tasks.push({ name, todos, task })
123
+ }
124
+ if (tasks.length > 0) projects.push({ name: meta.name, dir: meta.dir, importLines: [], tasks })
125
+ }
126
+ return {
127
+ headerNotes: [
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',
129
+ ],
130
+ projects,
131
+ extraFiles: [],
132
+ notes: [],
133
+ }
134
+ }