@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.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
package/src/workspace/filter.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Filter DSL — pnpm-style selectors for
|
|
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
|
-
|
|
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,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 {
|
|
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
|
|
147
|
+
const re = compileNameGlob(filter.matcher)
|
|
111
148
|
for (const p of projects) {
|
|
112
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
}
|
package/src/workspace/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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 './
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
+
}
|